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,523 @@
1
+ """MCP tools for BPA message operations.
2
+
3
+ Messages are reusable email/SMS/WhatsApp templates. Unlike notifications
4
+ (service-scoped), messages are global and can be linked to roles.
5
+
6
+ Write operations follow the audit-before-write pattern.
7
+
8
+ API Endpoints used:
9
+ - GET /message - List messages (paginated)
10
+ - GET /message/{message_id} - Get message by ID
11
+ - POST /message - Create message
12
+ - PUT /message - Update message
13
+ - DELETE /message/{message_id} - Delete message
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from mcp.server.fastmcp.exceptions import ToolError
21
+
22
+ from mcp_eregistrations_bpa.audit.context import (
23
+ NotAuthenticatedError,
24
+ get_current_user_email,
25
+ )
26
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
27
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
28
+ from mcp_eregistrations_bpa.bpa_client.errors import (
29
+ BPAClientError,
30
+ BPANotFoundError,
31
+ translate_error,
32
+ )
33
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
34
+
35
+ __all__ = [
36
+ "message_list",
37
+ "message_get",
38
+ "message_create",
39
+ "message_update",
40
+ "message_delete",
41
+ "register_message_tools",
42
+ ]
43
+
44
+
45
+ def _transform_message_response(data: dict[str, Any]) -> dict[str, Any]:
46
+ """Transform message API response from camelCase to snake_case.
47
+
48
+ Based on Message model from BPA frontend:
49
+ - id, name, code, subject, content
50
+ - messageType (MESSAGE/ALERT), channel (EMAIL/SMS/WHATSAPP)
51
+ - businessKey, roleRegistrations, messageRoleStatuses, messageBots
52
+
53
+ Args:
54
+ data: Raw API response with camelCase keys.
55
+
56
+ Returns:
57
+ dict: Transformed response with snake_case keys.
58
+ """
59
+ return {
60
+ "id": data.get("id"),
61
+ "name": data.get("name"),
62
+ "code": data.get("code"),
63
+ "subject": data.get("subject"),
64
+ "content": data.get("content"),
65
+ "message_type": data.get("messageType"),
66
+ "channel": data.get("channel"),
67
+ "business_key": data.get("businessKey"),
68
+ "role_registrations": data.get("roleRegistrations"),
69
+ "message_role_statuses": data.get("messageRoleStatuses"),
70
+ "message_bots": data.get("messageBots"),
71
+ # Audit fields
72
+ "created_by": data.get("createdBy"),
73
+ "created_when": data.get("createdWhen"),
74
+ "last_changed_by": data.get("lastChangedBy"),
75
+ "last_changed_when": data.get("lastChangedWhen"),
76
+ }
77
+
78
+
79
+ @large_response_handler(
80
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
81
+ navigation={
82
+ "list_all": "jq '.messages'",
83
+ "find_by_channel": "jq '.messages[] | select(.channel == \"EMAIL\")'",
84
+ "find_by_type": "jq '.messages[] | select(.message_type == \"MESSAGE\")'",
85
+ "find_by_name": "jq '.messages[] | select(.name | contains(\"search\"))'",
86
+ },
87
+ )
88
+ async def message_list(
89
+ page: int = 0,
90
+ size: int = 50,
91
+ channel: str | None = None,
92
+ name_filter: str | None = None,
93
+ ) -> dict[str, Any]:
94
+ """List BPA messages (global reusable templates).
95
+
96
+ Large responses (>50KB) are saved to file with navigation hints.
97
+
98
+ Args:
99
+ page: Page number, 0-indexed (default: 0).
100
+ size: Page size (default: 50).
101
+ channel: Filter by channel: EMAIL, SMS, WHATSAPP (optional).
102
+ name_filter: Filter by name substring (optional).
103
+
104
+ Returns:
105
+ dict with messages, total, page, size, has_more.
106
+ """
107
+ if page < 0:
108
+ page = 0
109
+ if size <= 0:
110
+ size = 50
111
+
112
+ try:
113
+ async with BPAClient() as client:
114
+ # Build query params for pagination
115
+ params: dict[str, Any] = {
116
+ "page": page,
117
+ "size": size,
118
+ }
119
+ if channel:
120
+ params["channel"] = channel.upper()
121
+ if name_filter:
122
+ params["filter"] = name_filter
123
+
124
+ messages_data = await client.get_list(
125
+ "/message",
126
+ params=params,
127
+ resource_type="message",
128
+ )
129
+ except BPAClientError as e:
130
+ raise translate_error(e, resource_type="message")
131
+
132
+ messages = [_transform_message_response(m) for m in messages_data]
133
+
134
+ return {
135
+ "messages": messages,
136
+ "total": len(messages),
137
+ "page": page,
138
+ "size": size,
139
+ "has_more": len(messages) == size,
140
+ }
141
+
142
+
143
+ async def message_get(message_id: str) -> dict[str, Any]:
144
+ """Get a BPA message by ID.
145
+
146
+ Args:
147
+ message_id: The message ID.
148
+
149
+ Returns:
150
+ dict with id, name, code, subject, content, message_type, channel.
151
+ """
152
+ if not message_id:
153
+ raise ToolError(
154
+ "Cannot get message: 'message_id' is required. "
155
+ "Use 'message_list' to find valid IDs."
156
+ )
157
+
158
+ try:
159
+ async with BPAClient() as client:
160
+ try:
161
+ message_data = await client.get(
162
+ "/message/{message_id}",
163
+ path_params={"message_id": message_id},
164
+ resource_type="message",
165
+ resource_id=message_id,
166
+ )
167
+ except BPANotFoundError:
168
+ raise ToolError(
169
+ f"Message '{message_id}' not found. "
170
+ "Use 'message_list' to see available messages."
171
+ )
172
+ except ToolError:
173
+ raise
174
+ except BPAClientError as e:
175
+ raise translate_error(e, resource_type="message", resource_id=message_id)
176
+
177
+ return _transform_message_response(message_data)
178
+
179
+
180
+ async def message_create(
181
+ name: str,
182
+ channel: str = "EMAIL",
183
+ message_type: str = "MESSAGE",
184
+ subject: str | None = None,
185
+ content: str | None = None,
186
+ code: str | None = None,
187
+ ) -> dict[str, Any]:
188
+ """Create a BPA message template. Audited write operation.
189
+
190
+ Args:
191
+ name: Message name (required).
192
+ channel: EMAIL, SMS, or WHATSAPP (default: EMAIL).
193
+ message_type: MESSAGE or ALERT (default: MESSAGE).
194
+ subject: Email subject line (optional).
195
+ content: Message body/template (optional).
196
+ code: Unique code identifier (optional).
197
+
198
+ Returns:
199
+ dict with id, name, channel, message_type, audit_id.
200
+ """
201
+ # Pre-flight validation
202
+ if not name or not name.strip():
203
+ raise ToolError("Message name is required.")
204
+
205
+ channel = (channel or "EMAIL").upper()
206
+ if channel not in ("EMAIL", "SMS", "WHATSAPP"):
207
+ raise ToolError("Channel must be EMAIL, SMS, or WHATSAPP.")
208
+
209
+ message_type = (message_type or "MESSAGE").upper()
210
+ if message_type not in ("MESSAGE", "ALERT"):
211
+ raise ToolError("Message type must be MESSAGE or ALERT.")
212
+
213
+ # Get authenticated user for audit
214
+ try:
215
+ user_email = get_current_user_email()
216
+ except NotAuthenticatedError as e:
217
+ raise ToolError(str(e))
218
+
219
+ # Build payload matching BPA Message model
220
+ payload: dict[str, Any] = {
221
+ "name": name.strip(),
222
+ "channel": channel,
223
+ "messageType": message_type,
224
+ }
225
+ if subject:
226
+ payload["subject"] = subject
227
+ if content:
228
+ payload["content"] = content
229
+ if code:
230
+ payload["code"] = code
231
+
232
+ audit_logger = AuditLogger()
233
+
234
+ try:
235
+ async with BPAClient() as client:
236
+ # Create PENDING audit record
237
+ audit_id = await audit_logger.record_pending(
238
+ user_email=user_email,
239
+ operation_type="create",
240
+ object_type="message",
241
+ params=payload,
242
+ )
243
+
244
+ try:
245
+ message_data = await client.post(
246
+ "/message",
247
+ json=payload,
248
+ resource_type="message",
249
+ )
250
+
251
+ # Save rollback state
252
+ created_id = message_data.get("id")
253
+ await audit_logger.save_rollback_state(
254
+ audit_id=audit_id,
255
+ object_type="message",
256
+ object_id=str(created_id),
257
+ previous_state={
258
+ "id": created_id,
259
+ "name": message_data.get("name"),
260
+ "_operation": "create",
261
+ },
262
+ )
263
+
264
+ await audit_logger.mark_success(
265
+ audit_id=audit_id,
266
+ result={"id": created_id, "name": message_data.get("name")},
267
+ )
268
+
269
+ except BPAClientError as e:
270
+ await audit_logger.mark_failed(audit_id=audit_id, error_message=str(e))
271
+ raise translate_error(e, resource_type="message")
272
+
273
+ except ToolError:
274
+ raise
275
+ except BPAClientError as e:
276
+ raise translate_error(e, resource_type="message")
277
+
278
+ result = _transform_message_response(message_data)
279
+ result["audit_id"] = audit_id
280
+ return result
281
+
282
+
283
+ async def message_update(
284
+ message_id: str,
285
+ name: str | None = None,
286
+ subject: str | None = None,
287
+ content: str | None = None,
288
+ channel: str | None = None,
289
+ message_type: str | None = None,
290
+ code: str | None = None,
291
+ ) -> dict[str, Any]:
292
+ """Update a BPA message. Audited write operation.
293
+
294
+ Args:
295
+ message_id: Message ID to update (required).
296
+ name: New message name (optional).
297
+ subject: New subject line (optional).
298
+ content: New message body (optional).
299
+ channel: New channel: EMAIL, SMS, WHATSAPP (optional).
300
+ message_type: New type: MESSAGE, ALERT (optional).
301
+ code: New code identifier (optional).
302
+
303
+ Returns:
304
+ dict with id, name, channel, previous_state, audit_id.
305
+ """
306
+ if not message_id:
307
+ raise ToolError(
308
+ "Cannot update message: 'message_id' is required. "
309
+ "Use 'message_list' to find valid IDs."
310
+ )
311
+
312
+ # At least one field must be provided
313
+ if all(v is None for v in [name, subject, content, channel, message_type, code]):
314
+ raise ToolError("At least one field must be provided for update.")
315
+
316
+ # Validate channel if provided
317
+ if channel:
318
+ channel = channel.upper()
319
+ if channel not in ("EMAIL", "SMS", "WHATSAPP"):
320
+ raise ToolError("Channel must be EMAIL, SMS, or WHATSAPP.")
321
+
322
+ # Validate message_type if provided
323
+ if message_type:
324
+ message_type = message_type.upper()
325
+ if message_type not in ("MESSAGE", "ALERT"):
326
+ raise ToolError("Message type must be MESSAGE or ALERT.")
327
+
328
+ # Get authenticated user for audit
329
+ try:
330
+ user_email = get_current_user_email()
331
+ except NotAuthenticatedError as e:
332
+ raise ToolError(str(e))
333
+
334
+ audit_logger = AuditLogger()
335
+
336
+ try:
337
+ async with BPAClient() as client:
338
+ # Capture current state for rollback
339
+ try:
340
+ previous_state = await client.get(
341
+ "/message/{message_id}",
342
+ path_params={"message_id": message_id},
343
+ resource_type="message",
344
+ resource_id=message_id,
345
+ )
346
+ except BPANotFoundError:
347
+ raise ToolError(
348
+ f"Message '{message_id}' not found. "
349
+ "Use 'message_list' to see available messages."
350
+ )
351
+
352
+ # Merge with current state - PUT /message requires full object
353
+ prev = previous_state
354
+ payload: dict[str, Any] = {
355
+ "id": message_id,
356
+ "name": name.strip() if name else prev.get("name"),
357
+ "subject": subject if subject is not None else prev.get("subject"),
358
+ "content": content if content is not None else prev.get("content"),
359
+ "channel": channel if channel else prev.get("channel"),
360
+ "messageType": (
361
+ message_type if message_type else prev.get("messageType")
362
+ ),
363
+ "code": code if code is not None else prev.get("code"),
364
+ }
365
+ # Preserve linked data
366
+ if previous_state.get("roleRegistrations"):
367
+ payload["roleRegistrations"] = previous_state["roleRegistrations"]
368
+ if previous_state.get("messageRoleStatuses"):
369
+ payload["messageRoleStatuses"] = previous_state["messageRoleStatuses"]
370
+ if previous_state.get("messageBots"):
371
+ payload["messageBots"] = previous_state["messageBots"]
372
+ if previous_state.get("businessKey"):
373
+ payload["businessKey"] = previous_state["businessKey"]
374
+
375
+ # Create PENDING audit record
376
+ audit_id = await audit_logger.record_pending(
377
+ user_email=user_email,
378
+ operation_type="update",
379
+ object_type="message",
380
+ object_id=message_id,
381
+ params={"changes": payload},
382
+ )
383
+
384
+ # Save rollback state
385
+ await audit_logger.save_rollback_state(
386
+ audit_id=audit_id,
387
+ object_type="message",
388
+ object_id=message_id,
389
+ previous_state=previous_state,
390
+ )
391
+
392
+ try:
393
+ message_data = await client.put(
394
+ "/message",
395
+ json=payload,
396
+ resource_type="message",
397
+ resource_id=message_id,
398
+ )
399
+
400
+ await audit_logger.mark_success(
401
+ audit_id=audit_id,
402
+ result={
403
+ "id": message_data.get("id"),
404
+ "name": message_data.get("name"),
405
+ },
406
+ )
407
+
408
+ except BPAClientError as e:
409
+ await audit_logger.mark_failed(audit_id=audit_id, error_message=str(e))
410
+ raise translate_error(
411
+ e, resource_type="message", resource_id=message_id
412
+ )
413
+
414
+ except ToolError:
415
+ raise
416
+ except BPAClientError as e:
417
+ raise translate_error(e, resource_type="message", resource_id=message_id)
418
+
419
+ result = _transform_message_response(message_data)
420
+ result["previous_state"] = _transform_message_response(previous_state)
421
+ result["audit_id"] = audit_id
422
+ return result
423
+
424
+
425
+ async def message_delete(message_id: str) -> dict[str, Any]:
426
+ """Delete a BPA message. Audited write operation.
427
+
428
+ Args:
429
+ message_id: Message ID to delete.
430
+
431
+ Returns:
432
+ dict with deleted (bool), message_id, deleted_message, audit_id.
433
+ """
434
+ if not message_id:
435
+ raise ToolError(
436
+ "Cannot delete message: 'message_id' is required. "
437
+ "Use 'message_list' to find valid IDs."
438
+ )
439
+
440
+ # Get authenticated user for audit
441
+ try:
442
+ user_email = get_current_user_email()
443
+ except NotAuthenticatedError as e:
444
+ raise ToolError(str(e))
445
+
446
+ audit_logger = AuditLogger()
447
+
448
+ try:
449
+ async with BPAClient() as client:
450
+ # Capture current state for rollback
451
+ try:
452
+ previous_state = await client.get(
453
+ "/message/{message_id}",
454
+ path_params={"message_id": message_id},
455
+ resource_type="message",
456
+ resource_id=message_id,
457
+ )
458
+ except BPANotFoundError:
459
+ raise ToolError(
460
+ f"Message '{message_id}' not found. "
461
+ "Use 'message_list' to see available messages."
462
+ )
463
+
464
+ # Create PENDING audit record
465
+ audit_id = await audit_logger.record_pending(
466
+ user_email=user_email,
467
+ operation_type="delete",
468
+ object_type="message",
469
+ object_id=message_id,
470
+ params={},
471
+ )
472
+
473
+ # Save rollback state
474
+ await audit_logger.save_rollback_state(
475
+ audit_id=audit_id,
476
+ object_type="message",
477
+ object_id=message_id,
478
+ previous_state=previous_state,
479
+ )
480
+
481
+ try:
482
+ await client.delete(
483
+ "/message/{message_id}",
484
+ path_params={"message_id": message_id},
485
+ resource_type="message",
486
+ resource_id=message_id,
487
+ )
488
+
489
+ await audit_logger.mark_success(
490
+ audit_id=audit_id,
491
+ result={"deleted": True, "message_id": message_id},
492
+ )
493
+
494
+ except BPAClientError as e:
495
+ await audit_logger.mark_failed(audit_id=audit_id, error_message=str(e))
496
+ raise translate_error(
497
+ e, resource_type="message", resource_id=message_id
498
+ )
499
+
500
+ except ToolError:
501
+ raise
502
+ except BPAClientError as e:
503
+ raise translate_error(e, resource_type="message", resource_id=message_id)
504
+
505
+ return {
506
+ "deleted": True,
507
+ "message_id": message_id,
508
+ "deleted_message": _transform_message_response(previous_state),
509
+ "audit_id": audit_id,
510
+ }
511
+
512
+
513
+ def register_message_tools(mcp: Any) -> None:
514
+ """Register message tools with the MCP server.
515
+
516
+ Args:
517
+ mcp: The FastMCP server instance.
518
+ """
519
+ mcp.tool()(message_list)
520
+ mcp.tool()(message_get)
521
+ mcp.tool()(message_create)
522
+ mcp.tool()(message_update)
523
+ mcp.tool()(message_delete)