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,1236 @@
1
+ """MCP tools for BPA role operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, updating,
4
+ and deleting BPA roles. Roles are access control entities that define
5
+ user permissions within a service.
6
+
7
+ Write operations follow the audit-before-write pattern:
8
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
9
+ 2. Create PENDING audit record
10
+ 3. Execute BPA API call
11
+ 4. Update audit record to SUCCESS or FAILED
12
+
13
+ API Endpoints used:
14
+ - GET /service/{service_id}/role - List roles for a service
15
+ - GET /role/{role_id} - Get role by ID
16
+ - POST /service/{service_id}/role - Create role within service
17
+ - PUT /role - Update role
18
+ - DELETE /role/{role_id} - Delete role
19
+ - POST /role/{role_id}/role_institution - Assign institution to role
20
+ - POST /role/{role_id}/role_registration - Assign registration to role
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from mcp.server.fastmcp.exceptions import ToolError
28
+
29
+ from mcp_eregistrations_bpa.audit.context import (
30
+ NotAuthenticatedError,
31
+ get_current_user_email,
32
+ )
33
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
34
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
35
+ from mcp_eregistrations_bpa.bpa_client.errors import (
36
+ BPAClientError,
37
+ BPANotFoundError,
38
+ translate_error,
39
+ )
40
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
41
+
42
+ __all__ = [
43
+ "role_list",
44
+ "role_get",
45
+ "role_create",
46
+ "role_update",
47
+ "role_delete",
48
+ "roleinstitution_create",
49
+ "roleregistration_create",
50
+ "register_role_tools",
51
+ ]
52
+
53
+
54
+ def _transform_role_response(data: dict[str, Any]) -> dict[str, Any]:
55
+ """Transform role API response from camelCase to snake_case.
56
+
57
+ Args:
58
+ data: Raw API response with camelCase keys.
59
+
60
+ Returns:
61
+ dict: Transformed response with snake_case keys.
62
+ """
63
+ return {
64
+ # Core fields
65
+ "id": data.get("id"),
66
+ "name": data.get("name"),
67
+ "short_name": data.get("shortName"),
68
+ "role_type": data.get("type"),
69
+ "assigned_to": data.get("assignedTo"),
70
+ "description": data.get("description"),
71
+ "service_id": data.get("serviceId"),
72
+ # Workflow configuration
73
+ "start_role": data.get("startRole", False),
74
+ "visible_for_applicant": data.get("visibleForApplicant", True),
75
+ "sort_order_number": data.get("sortOrderNumber", 0),
76
+ "used_in_flow": data.get("usedInFlow", False),
77
+ # Permissions
78
+ "allow_to_confirm_payments": data.get("allowToConfirmPayments", False),
79
+ "allow_access_to_financial_reports": data.get(
80
+ "allowAccessToFinancialReports", False
81
+ ),
82
+ # Linked entities (read-only)
83
+ "registrations": data.get("registrations", []),
84
+ "role_institutions": data.get("roleInstitutions", []),
85
+ "statuses": data.get("statuses", []),
86
+ # BotRole-specific fields (only present for BotRole type)
87
+ "repeat_until_successful": data.get("repeatUntilSuccessful"),
88
+ "repeat_in_minutes": data.get("repeatInMinutes"),
89
+ "repeat_in_hours": data.get("repeatInHours"),
90
+ "repeat_in_days": data.get("repeatInDays"),
91
+ "duration_in_minutes": data.get("durationInMinutes"),
92
+ "duration_in_hours": data.get("durationInHours"),
93
+ "duration_in_days": data.get("durationInDays"),
94
+ "bot_role_bots": data.get("botRoleBots"),
95
+ # Audit fields
96
+ "created_by": data.get("createdBy"),
97
+ "created_when": data.get("createdWhen"),
98
+ "last_changed_by": data.get("lastChangedBy"),
99
+ "last_changed_when": data.get("lastChangedWhen"),
100
+ }
101
+
102
+
103
+ @large_response_handler(
104
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
105
+ navigation={
106
+ "list_all": "jq '.roles'",
107
+ "find_by_type": "jq '.roles[] | select(.role_type == \"UserRole\")'",
108
+ "find_by_name": "jq '.roles[] | select(.name | contains(\"search\"))'",
109
+ "start_roles": "jq '.roles[] | select(.start_role == true)'",
110
+ },
111
+ )
112
+ async def role_list(service_id: str | int) -> dict[str, Any]:
113
+ """List all roles for a BPA service.
114
+
115
+ Returns roles configured for the specified service.
116
+ Large responses (>50KB) are saved to file with navigation hints.
117
+
118
+ Args:
119
+ service_id: The service ID to list roles for (required).
120
+
121
+ Returns:
122
+ dict with roles, service_id, total.
123
+ """
124
+ if not service_id:
125
+ raise ToolError(
126
+ "Cannot list roles: 'service_id' is required. "
127
+ "Use 'service_list' to find valid service IDs."
128
+ )
129
+
130
+ try:
131
+ async with BPAClient() as client:
132
+ try:
133
+ roles_data = await client.get_list(
134
+ "/service/{service_id}/role",
135
+ path_params={"service_id": service_id},
136
+ resource_type="role",
137
+ )
138
+ except BPANotFoundError:
139
+ raise ToolError(
140
+ f"Service '{service_id}' not found. "
141
+ "Use 'service_list' to see available services."
142
+ )
143
+ except ToolError:
144
+ raise
145
+ except BPAClientError as e:
146
+ raise translate_error(e, resource_type="role")
147
+
148
+ # Transform to consistent output format
149
+ roles = [_transform_role_response(role) for role in roles_data]
150
+
151
+ return {
152
+ "roles": roles,
153
+ "service_id": service_id,
154
+ "total": len(roles),
155
+ }
156
+
157
+
158
+ async def _resolve_destination_role_names(
159
+ client: BPAClient, statuses: list[dict[str, Any]]
160
+ ) -> list[dict[str, Any]]:
161
+ """Resolve destination IDs in statuses to role names.
162
+
163
+ For each status with a destinationId, fetches the destination role
164
+ to get its name. Uses caching to avoid duplicate lookups.
165
+
166
+ Args:
167
+ client: Active BPAClient connection.
168
+ statuses: List of status objects from role response.
169
+
170
+ Returns:
171
+ list: Enhanced status objects with destination_role_name field.
172
+ """
173
+ if not statuses:
174
+ return []
175
+
176
+ # Build mapping of destination_id -> role_name (with caching)
177
+ destination_ids: set[str] = set()
178
+ for status in statuses:
179
+ dest_id = status.get("destinationId")
180
+ if dest_id:
181
+ destination_ids.add(dest_id)
182
+
183
+ # Fetch role names for all unique destination IDs
184
+ id_to_name: dict[str, str | None] = {}
185
+ for dest_id in destination_ids:
186
+ try:
187
+ dest_role = await client.get(
188
+ "/role/{role_id}",
189
+ path_params={"role_id": dest_id},
190
+ resource_type="role",
191
+ resource_id=dest_id,
192
+ )
193
+ id_to_name[dest_id] = dest_role.get("name")
194
+ except BPANotFoundError:
195
+ # Graceful fallback: role may have been deleted
196
+ id_to_name[dest_id] = None
197
+ except BPAClientError:
198
+ # Other errors: still graceful fallback
199
+ id_to_name[dest_id] = None
200
+
201
+ # Enhance statuses with resolved names
202
+ enhanced_statuses = []
203
+ for status in statuses:
204
+ enhanced = {
205
+ "name": status.get("name"),
206
+ "type": status.get("type"),
207
+ }
208
+ dest_id = status.get("destinationId")
209
+ if dest_id:
210
+ enhanced["destination_id"] = dest_id
211
+ enhanced["destination_role_name"] = id_to_name.get(dest_id)
212
+ enhanced_statuses.append(enhanced)
213
+
214
+ return enhanced_statuses
215
+
216
+
217
+ async def role_get(role_id: str | int) -> dict[str, Any]:
218
+ """Get details of a BPA role by ID.
219
+
220
+ Returns complete role details with resolved destination role names.
221
+
222
+ Args:
223
+ role_id: The unique identifier of the role.
224
+
225
+ Returns:
226
+ dict: Complete role details including:
227
+ - id, name, description
228
+ - service_id: The parent service ID
229
+ - statuses: Array with destination_id and destination_role_name resolved
230
+ """
231
+ if not role_id:
232
+ raise ToolError(
233
+ "Cannot get role: 'role_id' is required. "
234
+ "Use 'role_list' with service_id to find valid role IDs."
235
+ )
236
+
237
+ try:
238
+ async with BPAClient() as client:
239
+ try:
240
+ role_data = await client.get(
241
+ "/role/{role_id}",
242
+ path_params={"role_id": role_id},
243
+ resource_type="role",
244
+ resource_id=role_id,
245
+ )
246
+ except BPANotFoundError:
247
+ raise ToolError(
248
+ f"Role '{role_id}' not found. "
249
+ "Use 'role_list' with service_id to see available roles."
250
+ )
251
+
252
+ # Resolve destination IDs in statuses to role names
253
+ raw_statuses = role_data.get("statuses", [])
254
+ enhanced_statuses = await _resolve_destination_role_names(
255
+ client, raw_statuses
256
+ )
257
+
258
+ except ToolError:
259
+ raise
260
+ except BPAClientError as e:
261
+ raise translate_error(e, resource_type="role", resource_id=role_id)
262
+
263
+ result = _transform_role_response(role_data)
264
+ # Override statuses with enhanced version containing resolved names
265
+ result["statuses"] = enhanced_statuses
266
+ return result
267
+
268
+
269
+ def _validate_role_create_params(
270
+ service_id: str | int,
271
+ name: str,
272
+ short_name: str,
273
+ assigned_to: str,
274
+ role_type: str,
275
+ description: str | None,
276
+ start_role: bool,
277
+ visible_for_applicant: bool,
278
+ sort_order_number: int,
279
+ allow_to_confirm_payments: bool,
280
+ allow_access_to_financial_reports: bool,
281
+ # BotRole-specific parameters
282
+ repeat_until_successful: bool | None,
283
+ repeat_in_minutes: int | None,
284
+ repeat_in_hours: int | None,
285
+ repeat_in_days: int | None,
286
+ duration_in_minutes: int | None,
287
+ duration_in_hours: int | None,
288
+ duration_in_days: int | None,
289
+ ) -> dict[str, Any]:
290
+ """Validate role_create parameters (pre-flight).
291
+
292
+ Returns validated params dict or raises ToolError if invalid.
293
+ No audit record is created for validation failures.
294
+
295
+ Args:
296
+ service_id: Parent service ID (required).
297
+ name: Role name (required).
298
+ short_name: Short name for the role (required by BPA API).
299
+ assigned_to: Role pool assignment string (e.g., "processing").
300
+ role_type: Role type - "UserRole" for humans, "BotRole" for automation.
301
+ description: Role description (optional).
302
+ start_role: Whether this is the workflow entry point.
303
+ visible_for_applicant: Whether visible to applicants.
304
+ sort_order_number: Ordering in workflow.
305
+ allow_to_confirm_payments: Payment confirmation permission (UserRole only).
306
+ allow_access_to_financial_reports: Financial reports permission (UserRole only).
307
+ repeat_until_successful: Retry on failure (BotRole only).
308
+ repeat_in_minutes: Retry interval in minutes (BotRole only).
309
+ repeat_in_hours: Retry interval in hours (BotRole only).
310
+ repeat_in_days: Retry interval in days (BotRole only).
311
+ duration_in_minutes: Execution timeout in minutes (BotRole only).
312
+ duration_in_hours: Execution timeout in hours (BotRole only).
313
+ duration_in_days: Execution timeout in days (BotRole only).
314
+
315
+ Returns:
316
+ dict: Validated parameters ready for API call.
317
+
318
+ Raises:
319
+ ToolError: If validation fails.
320
+ """
321
+ errors = []
322
+
323
+ if not service_id:
324
+ errors.append("'service_id' is required")
325
+
326
+ if not name or not name.strip():
327
+ errors.append("'name' is required and cannot be empty")
328
+
329
+ if name and len(name.strip()) > 255:
330
+ errors.append("'name' must be 255 characters or less")
331
+
332
+ if not short_name or not short_name.strip():
333
+ errors.append("'short_name' is required and cannot be empty")
334
+
335
+ if short_name and len(short_name.strip()) > 50:
336
+ errors.append("'short_name' must be 50 characters or less")
337
+
338
+ if not assigned_to or not assigned_to.strip():
339
+ errors.append("'assigned_to' is required and cannot be empty")
340
+ elif len(assigned_to.strip()) < 2:
341
+ errors.append("'assigned_to' must be at least 2 characters")
342
+ elif len(assigned_to.strip()) > 255:
343
+ errors.append("'assigned_to' must be 255 characters or less")
344
+
345
+ valid_role_types = ["UserRole", "BotRole"]
346
+ if role_type not in valid_role_types:
347
+ errors.append(f"'role_type' must be one of: {', '.join(valid_role_types)}")
348
+
349
+ if errors:
350
+ error_msg = "; ".join(errors)
351
+ raise ToolError(f"Cannot create role: {error_msg}. Check required fields.")
352
+
353
+ params: dict[str, Any] = {
354
+ "name": name.strip(),
355
+ "shortName": short_name.strip(),
356
+ "type": role_type,
357
+ "assignedTo": assigned_to.strip(),
358
+ # Workflow configuration
359
+ "startRole": start_role,
360
+ "visibleForApplicant": visible_for_applicant,
361
+ "sortOrderNumber": sort_order_number,
362
+ }
363
+ if description:
364
+ params["description"] = description.strip()
365
+
366
+ # Add role-type-specific parameters
367
+ if role_type == "UserRole":
368
+ # UserRole-specific permissions
369
+ params["allowToConfirmPayments"] = allow_to_confirm_payments
370
+ params["allowAccessToFinancialReports"] = allow_access_to_financial_reports
371
+ elif role_type == "BotRole":
372
+ # BotRole-specific automation configuration
373
+ if repeat_until_successful is not None:
374
+ params["repeatUntilSuccessful"] = repeat_until_successful
375
+ if repeat_in_minutes is not None:
376
+ params["repeatInMinutes"] = repeat_in_minutes
377
+ if repeat_in_hours is not None:
378
+ params["repeatInHours"] = repeat_in_hours
379
+ if repeat_in_days is not None:
380
+ params["repeatInDays"] = repeat_in_days
381
+ if duration_in_minutes is not None:
382
+ params["durationInMinutes"] = duration_in_minutes
383
+ if duration_in_hours is not None:
384
+ params["durationInHours"] = duration_in_hours
385
+ if duration_in_days is not None:
386
+ params["durationInDays"] = duration_in_days
387
+
388
+ return params
389
+
390
+
391
+ async def role_create(
392
+ service_id: str | int,
393
+ name: str,
394
+ short_name: str,
395
+ assigned_to: str = "processing",
396
+ role_type: str = "UserRole",
397
+ description: str | None = None,
398
+ start_role: bool = False,
399
+ visible_for_applicant: bool = True,
400
+ sort_order_number: int = 0,
401
+ allow_to_confirm_payments: bool = False,
402
+ allow_access_to_financial_reports: bool = False,
403
+ # BotRole-specific parameters
404
+ repeat_until_successful: bool | None = None,
405
+ repeat_in_minutes: int | None = None,
406
+ repeat_in_hours: int | None = None,
407
+ repeat_in_days: int | None = None,
408
+ duration_in_minutes: int | None = None,
409
+ duration_in_hours: int | None = None,
410
+ duration_in_days: int | None = None,
411
+ ) -> dict[str, Any]:
412
+ """Create role in a service. Audited write operation.
413
+
414
+ Args:
415
+ service_id: Parent service ID.
416
+ name: Role name.
417
+ short_name: Short name (required by BPA).
418
+ assigned_to: Role pool (default: "processing").
419
+ role_type: "UserRole" or "BotRole" (default: "UserRole").
420
+ description: Optional description.
421
+ start_role: Workflow entry point (default: False).
422
+ visible_for_applicant: Visible to applicants (default: True).
423
+ sort_order_number: Workflow position (default: 0).
424
+ allow_to_confirm_payments: Payment permission (UserRole only).
425
+ allow_access_to_financial_reports: Reports permission (UserRole only).
426
+ repeat_until_successful: Retry on failure (BotRole only).
427
+ repeat_in_minutes/hours/days: Retry interval (BotRole only).
428
+ duration_in_minutes/hours/days: Execution timeout (BotRole only).
429
+
430
+ Returns:
431
+ dict with role details, service_id, audit_id.
432
+ """
433
+ # Pre-flight validation (no audit record for validation failures)
434
+ validated_params = _validate_role_create_params(
435
+ service_id,
436
+ name,
437
+ short_name,
438
+ assigned_to,
439
+ role_type,
440
+ description,
441
+ start_role,
442
+ visible_for_applicant,
443
+ sort_order_number,
444
+ allow_to_confirm_payments,
445
+ allow_access_to_financial_reports,
446
+ # BotRole-specific parameters
447
+ repeat_until_successful,
448
+ repeat_in_minutes,
449
+ repeat_in_hours,
450
+ repeat_in_days,
451
+ duration_in_minutes,
452
+ duration_in_hours,
453
+ duration_in_days,
454
+ )
455
+
456
+ # Get authenticated user for audit (before any API calls)
457
+ try:
458
+ user_email = get_current_user_email()
459
+ except NotAuthenticatedError as e:
460
+ raise ToolError(str(e))
461
+
462
+ # Use single BPAClient connection for all operations
463
+ try:
464
+ async with BPAClient() as client:
465
+ # Verify parent service exists before creating audit record
466
+ try:
467
+ await client.get(
468
+ "/service/{id}",
469
+ path_params={"id": service_id},
470
+ resource_type="service",
471
+ resource_id=service_id,
472
+ )
473
+ except BPANotFoundError:
474
+ raise ToolError(
475
+ f"Cannot create role: Service '{service_id}' not found. "
476
+ "Use 'service_list' to see available services."
477
+ )
478
+
479
+ # Create audit record BEFORE API call (audit-before-write pattern)
480
+ audit_logger = AuditLogger()
481
+ audit_id = await audit_logger.record_pending(
482
+ user_email=user_email,
483
+ operation_type="create",
484
+ object_type="role",
485
+ params={
486
+ "service_id": str(service_id),
487
+ **validated_params,
488
+ },
489
+ )
490
+
491
+ try:
492
+ role_data = await client.post(
493
+ "/service/{service_id}/role",
494
+ path_params={"service_id": service_id},
495
+ json=validated_params,
496
+ resource_type="role",
497
+ )
498
+
499
+ # Save rollback state (for create, save ID to enable deletion)
500
+ created_id = role_data.get("id")
501
+ await audit_logger.save_rollback_state(
502
+ audit_id=audit_id,
503
+ object_type="role",
504
+ object_id=str(created_id),
505
+ previous_state={
506
+ "id": created_id,
507
+ "name": role_data.get("name"),
508
+ "shortName": role_data.get("shortName"),
509
+ "type": role_data.get("type"),
510
+ "assignedTo": role_data.get("assignedTo"),
511
+ "description": role_data.get("description"),
512
+ "serviceId": str(service_id),
513
+ # Workflow configuration
514
+ "startRole": role_data.get("startRole"),
515
+ "visibleForApplicant": role_data.get("visibleForApplicant"),
516
+ "sortOrderNumber": role_data.get("sortOrderNumber"),
517
+ # UserRole permissions
518
+ "allowToConfirmPayments": role_data.get(
519
+ "allowToConfirmPayments"
520
+ ),
521
+ "allowAccessToFinancialReports": role_data.get(
522
+ "allowAccessToFinancialReports"
523
+ ),
524
+ # BotRole-specific fields
525
+ "repeatUntilSuccessful": role_data.get("repeatUntilSuccessful"),
526
+ "repeatInMinutes": role_data.get("repeatInMinutes"),
527
+ "repeatInHours": role_data.get("repeatInHours"),
528
+ "repeatInDays": role_data.get("repeatInDays"),
529
+ "durationInMinutes": role_data.get("durationInMinutes"),
530
+ "durationInHours": role_data.get("durationInHours"),
531
+ "durationInDays": role_data.get("durationInDays"),
532
+ "_operation": "create", # Marker for rollback to DELETE
533
+ },
534
+ )
535
+
536
+ # Mark audit as success
537
+ await audit_logger.mark_success(
538
+ audit_id,
539
+ result={
540
+ "role_id": role_data.get("id"),
541
+ "name": role_data.get("name"),
542
+ "service_id": str(service_id),
543
+ },
544
+ )
545
+
546
+ result = _transform_role_response(role_data)
547
+ result["service_id"] = service_id # Ensure service_id is always set
548
+ result["audit_id"] = audit_id
549
+ return result
550
+
551
+ except BPAClientError as e:
552
+ # Mark audit as failed
553
+ await audit_logger.mark_failed(audit_id, str(e))
554
+ raise translate_error(e, resource_type="role")
555
+
556
+ except ToolError:
557
+ raise
558
+ except BPAClientError as e:
559
+ raise translate_error(e, resource_type="service", resource_id=service_id)
560
+
561
+
562
+ def _validate_role_update_params(
563
+ role_id: str | int,
564
+ name: str | None,
565
+ short_name: str | None,
566
+ assigned_to: str | None,
567
+ description: str | None,
568
+ start_role: bool | None,
569
+ visible_for_applicant: bool | None,
570
+ sort_order_number: int | None,
571
+ allow_to_confirm_payments: bool | None,
572
+ allow_access_to_financial_reports: bool | None,
573
+ ) -> dict[str, Any]:
574
+ """Validate role_update parameters (pre-flight).
575
+
576
+ Returns validated params dict or raises ToolError if invalid.
577
+
578
+ Args:
579
+ role_id: ID of role to update (required).
580
+ name: New name (optional).
581
+ short_name: New short name (optional).
582
+ assigned_to: New role pool assignment string (optional).
583
+ description: New description (optional).
584
+ start_role: Whether this is the workflow entry point (optional).
585
+ visible_for_applicant: Whether visible to applicants (optional).
586
+ sort_order_number: Ordering in workflow (optional).
587
+ allow_to_confirm_payments: Payment confirmation permission (optional).
588
+ allow_access_to_financial_reports: Financial reports permission (optional).
589
+
590
+ Returns:
591
+ dict: Validated parameters ready for API call.
592
+
593
+ Raises:
594
+ ToolError: If validation fails.
595
+ """
596
+ errors = []
597
+
598
+ if not role_id:
599
+ errors.append("'role_id' is required")
600
+
601
+ if name is not None and not name.strip():
602
+ errors.append("'name' cannot be empty when provided")
603
+
604
+ if name and len(name.strip()) > 255:
605
+ errors.append("'name' must be 255 characters or less")
606
+
607
+ if short_name is not None and not short_name.strip():
608
+ errors.append("'short_name' cannot be empty when provided")
609
+
610
+ if short_name and len(short_name.strip()) > 50:
611
+ errors.append("'short_name' must be 50 characters or less")
612
+
613
+ if assigned_to is not None and not assigned_to.strip():
614
+ errors.append("'assigned_to' cannot be empty when provided")
615
+
616
+ # At least one field must be provided for update
617
+ all_none = (
618
+ name is None
619
+ and short_name is None
620
+ and assigned_to is None
621
+ and description is None
622
+ and start_role is None
623
+ and visible_for_applicant is None
624
+ and sort_order_number is None
625
+ and allow_to_confirm_payments is None
626
+ and allow_access_to_financial_reports is None
627
+ )
628
+ if all_none:
629
+ errors.append("At least one field must be provided for update")
630
+
631
+ if errors:
632
+ error_msg = "; ".join(errors)
633
+ raise ToolError(f"Cannot update role: {error_msg}. Check required fields.")
634
+
635
+ params: dict[str, Any] = {"id": role_id}
636
+ if name is not None:
637
+ params["name"] = name.strip()
638
+ if short_name is not None:
639
+ params["shortName"] = short_name.strip()
640
+ if assigned_to is not None:
641
+ params["assignedTo"] = assigned_to.strip()
642
+ if description is not None:
643
+ params["description"] = description.strip()
644
+ # Workflow configuration
645
+ if start_role is not None:
646
+ params["startRole"] = start_role
647
+ if visible_for_applicant is not None:
648
+ params["visibleForApplicant"] = visible_for_applicant
649
+ if sort_order_number is not None:
650
+ params["sortOrderNumber"] = sort_order_number
651
+ # Permissions
652
+ if allow_to_confirm_payments is not None:
653
+ params["allowToConfirmPayments"] = allow_to_confirm_payments
654
+ if allow_access_to_financial_reports is not None:
655
+ params["allowAccessToFinancialReports"] = allow_access_to_financial_reports
656
+
657
+ return params
658
+
659
+
660
+ async def role_update(
661
+ role_id: str | int,
662
+ name: str | None = None,
663
+ short_name: str | None = None,
664
+ assigned_to: str | None = None,
665
+ description: str | None = None,
666
+ start_role: bool | None = None,
667
+ visible_for_applicant: bool | None = None,
668
+ sort_order_number: int | None = None,
669
+ allow_to_confirm_payments: bool | None = None,
670
+ allow_access_to_financial_reports: bool | None = None,
671
+ ) -> dict[str, Any]:
672
+ """Update an existing BPA role. Audited write operation.
673
+
674
+ Args:
675
+ role_id: Role ID to update.
676
+ name: New name (optional).
677
+ short_name: New short name (optional).
678
+ assigned_to: New role pool (optional).
679
+ description: New description (optional).
680
+ start_role: Workflow entry point (optional).
681
+ visible_for_applicant: Visible to applicants (optional).
682
+ sort_order_number: Workflow position (optional).
683
+ allow_to_confirm_payments: Payment permission (optional).
684
+ allow_access_to_financial_reports: Reports permission (optional).
685
+
686
+ Returns:
687
+ dict with updated role, previous_state, audit_id.
688
+ """
689
+ # Pre-flight validation (no audit record for validation failures)
690
+ validated_params = _validate_role_update_params(
691
+ role_id,
692
+ name,
693
+ short_name,
694
+ assigned_to,
695
+ description,
696
+ start_role,
697
+ visible_for_applicant,
698
+ sort_order_number,
699
+ allow_to_confirm_payments,
700
+ allow_access_to_financial_reports,
701
+ )
702
+
703
+ # Get authenticated user for audit
704
+ try:
705
+ user_email = get_current_user_email()
706
+ except NotAuthenticatedError as e:
707
+ raise ToolError(str(e))
708
+
709
+ # Use single BPAClient connection for all operations
710
+ try:
711
+ async with BPAClient() as client:
712
+ # Capture current state for rollback BEFORE making changes
713
+ try:
714
+ previous_state = await client.get(
715
+ "/role/{role_id}",
716
+ path_params={"role_id": role_id},
717
+ resource_type="role",
718
+ resource_id=role_id,
719
+ )
720
+ except BPANotFoundError:
721
+ raise ToolError(
722
+ f"Role '{role_id}' not found. "
723
+ "Use 'role_list' with service_id to see available roles."
724
+ )
725
+
726
+ # Merge provided changes with current state for full object PUT
727
+ full_params = {
728
+ "id": role_id,
729
+ "name": validated_params.get("name", previous_state.get("name")),
730
+ "shortName": validated_params.get(
731
+ "shortName", previous_state.get("shortName")
732
+ ),
733
+ "assignedTo": validated_params.get(
734
+ "assignedTo", previous_state.get("assignedTo")
735
+ ),
736
+ "description": validated_params.get(
737
+ "description", previous_state.get("description")
738
+ ),
739
+ "serviceId": previous_state.get("serviceId"),
740
+ # Workflow configuration
741
+ "startRole": validated_params.get(
742
+ "startRole", previous_state.get("startRole", False)
743
+ ),
744
+ "visibleForApplicant": validated_params.get(
745
+ "visibleForApplicant",
746
+ previous_state.get("visibleForApplicant", True),
747
+ ),
748
+ "sortOrderNumber": validated_params.get(
749
+ "sortOrderNumber", previous_state.get("sortOrderNumber", 0)
750
+ ),
751
+ # Permissions
752
+ "allowToConfirmPayments": validated_params.get(
753
+ "allowToConfirmPayments",
754
+ previous_state.get("allowToConfirmPayments", False),
755
+ ),
756
+ "allowAccessToFinancialReports": validated_params.get(
757
+ "allowAccessToFinancialReports",
758
+ previous_state.get("allowAccessToFinancialReports", False),
759
+ ),
760
+ }
761
+
762
+ # Create audit record BEFORE API call (audit-before-write pattern)
763
+ audit_logger = AuditLogger()
764
+ audit_id = await audit_logger.record_pending(
765
+ user_email=user_email,
766
+ operation_type="update",
767
+ object_type="role",
768
+ object_id=str(role_id),
769
+ params={
770
+ "changes": validated_params,
771
+ },
772
+ )
773
+
774
+ # Save rollback state for undo capability
775
+ await audit_logger.save_rollback_state(
776
+ audit_id=audit_id,
777
+ object_type="role",
778
+ object_id=str(role_id),
779
+ previous_state={
780
+ "id": previous_state.get("id"),
781
+ "name": previous_state.get("name"),
782
+ "shortName": previous_state.get("shortName"),
783
+ "type": previous_state.get("type"),
784
+ "assignedTo": previous_state.get("assignedTo"),
785
+ "description": previous_state.get("description"),
786
+ "serviceId": previous_state.get("serviceId"),
787
+ # Workflow configuration
788
+ "startRole": previous_state.get("startRole"),
789
+ "visibleForApplicant": previous_state.get("visibleForApplicant"),
790
+ "sortOrderNumber": previous_state.get("sortOrderNumber"),
791
+ # UserRole permissions
792
+ "allowToConfirmPayments": previous_state.get(
793
+ "allowToConfirmPayments"
794
+ ),
795
+ "allowAccessToFinancialReports": previous_state.get(
796
+ "allowAccessToFinancialReports"
797
+ ),
798
+ # BotRole-specific fields
799
+ "repeatUntilSuccessful": previous_state.get(
800
+ "repeatUntilSuccessful"
801
+ ),
802
+ "repeatInMinutes": previous_state.get("repeatInMinutes"),
803
+ "repeatInHours": previous_state.get("repeatInHours"),
804
+ "repeatInDays": previous_state.get("repeatInDays"),
805
+ "durationInMinutes": previous_state.get("durationInMinutes"),
806
+ "durationInHours": previous_state.get("durationInHours"),
807
+ "durationInDays": previous_state.get("durationInDays"),
808
+ },
809
+ )
810
+
811
+ try:
812
+ role_data = await client.put(
813
+ "/role",
814
+ json=full_params,
815
+ resource_type="role",
816
+ resource_id=role_id,
817
+ )
818
+
819
+ # Mark audit as success
820
+ await audit_logger.mark_success(
821
+ audit_id,
822
+ result={
823
+ "role_id": role_data.get("id"),
824
+ "name": role_data.get("name"),
825
+ "changes_applied": {
826
+ k: v for k, v in validated_params.items() if k != "id"
827
+ },
828
+ },
829
+ )
830
+
831
+ result = _transform_role_response(role_data)
832
+ result["previous_state"] = {
833
+ "name": previous_state.get("name"),
834
+ "short_name": previous_state.get("shortName"),
835
+ "assigned_to": previous_state.get("assignedTo"),
836
+ "description": previous_state.get("description"),
837
+ # Workflow configuration
838
+ "start_role": previous_state.get("startRole"),
839
+ "visible_for_applicant": previous_state.get("visibleForApplicant"),
840
+ "sort_order_number": previous_state.get("sortOrderNumber"),
841
+ # Permissions
842
+ "allow_to_confirm_payments": previous_state.get(
843
+ "allowToConfirmPayments"
844
+ ),
845
+ "allow_access_to_financial_reports": previous_state.get(
846
+ "allowAccessToFinancialReports"
847
+ ),
848
+ }
849
+ result["audit_id"] = audit_id
850
+ return result
851
+
852
+ except BPAClientError as e:
853
+ # Mark audit as failed
854
+ await audit_logger.mark_failed(audit_id, str(e))
855
+ raise translate_error(e, resource_type="role", resource_id=role_id)
856
+
857
+ except ToolError:
858
+ raise
859
+ except BPAClientError as e:
860
+ raise translate_error(e, resource_type="role", resource_id=role_id)
861
+
862
+
863
+ def _validate_role_delete_params(role_id: str | int) -> None:
864
+ """Validate role_delete parameters (pre-flight).
865
+
866
+ Raises ToolError if validation fails.
867
+
868
+ Args:
869
+ role_id: Role ID to delete (required).
870
+
871
+ Raises:
872
+ ToolError: If validation fails.
873
+ """
874
+ if not role_id:
875
+ raise ToolError(
876
+ "Cannot delete role: 'role_id' is required. "
877
+ "Use 'role_list' with service_id to find valid role IDs."
878
+ )
879
+
880
+
881
+ async def role_delete(role_id: str | int) -> dict[str, Any]:
882
+ """Delete a BPA role. Audited write operation.
883
+
884
+ Known Issue: BPA may return "Camunda publish problem" - contact administrator.
885
+
886
+ Args:
887
+ role_id: Role ID to delete.
888
+
889
+ Returns:
890
+ dict with deleted (bool), role_id, deleted_role, audit_id.
891
+ """
892
+ # Pre-flight validation (no audit record for validation failures)
893
+ _validate_role_delete_params(role_id)
894
+
895
+ # Get authenticated user for audit
896
+ try:
897
+ user_email = get_current_user_email()
898
+ except NotAuthenticatedError as e:
899
+ raise ToolError(str(e))
900
+
901
+ # Use single BPAClient connection for all operations
902
+ try:
903
+ async with BPAClient() as client:
904
+ # Capture current state for rollback BEFORE making changes
905
+ try:
906
+ previous_state = await client.get(
907
+ "/role/{role_id}",
908
+ path_params={"role_id": role_id},
909
+ resource_type="role",
910
+ resource_id=role_id,
911
+ )
912
+ except BPANotFoundError:
913
+ raise ToolError(
914
+ f"Role '{role_id}' not found. "
915
+ "Use 'role_list' with service_id to see available roles."
916
+ )
917
+
918
+ # Normalize previous_state to snake_case for consistency
919
+ normalized_previous_state = {
920
+ "id": previous_state.get("id"),
921
+ "name": previous_state.get("name"),
922
+ "assigned_to": previous_state.get("assignedTo"),
923
+ "description": previous_state.get("description"),
924
+ "service_id": previous_state.get("serviceId"),
925
+ # Workflow configuration
926
+ "start_role": previous_state.get("startRole"),
927
+ "visible_for_applicant": previous_state.get("visibleForApplicant"),
928
+ "sort_order_number": previous_state.get("sortOrderNumber"),
929
+ # Permissions
930
+ "allow_to_confirm_payments": previous_state.get(
931
+ "allowToConfirmPayments"
932
+ ),
933
+ "allow_access_to_financial_reports": previous_state.get(
934
+ "allowAccessToFinancialReports"
935
+ ),
936
+ }
937
+
938
+ # Create audit record BEFORE API call (audit-before-write pattern)
939
+ audit_logger = AuditLogger()
940
+ audit_id = await audit_logger.record_pending(
941
+ user_email=user_email,
942
+ operation_type="delete",
943
+ object_type="role",
944
+ object_id=str(role_id),
945
+ params={},
946
+ )
947
+
948
+ # Save rollback state for undo capability (recreate on rollback)
949
+ await audit_logger.save_rollback_state(
950
+ audit_id=audit_id,
951
+ object_type="role",
952
+ object_id=str(role_id),
953
+ previous_state={
954
+ "id": previous_state.get("id"),
955
+ "name": previous_state.get("name"),
956
+ "shortName": previous_state.get("shortName"),
957
+ "type": previous_state.get("type"),
958
+ "assignedTo": previous_state.get("assignedTo"),
959
+ "description": previous_state.get("description"),
960
+ "serviceId": previous_state.get("serviceId"),
961
+ # Workflow configuration
962
+ "startRole": previous_state.get("startRole"),
963
+ "visibleForApplicant": previous_state.get("visibleForApplicant"),
964
+ "sortOrderNumber": previous_state.get("sortOrderNumber"),
965
+ # UserRole permissions
966
+ "allowToConfirmPayments": previous_state.get(
967
+ "allowToConfirmPayments"
968
+ ),
969
+ "allowAccessToFinancialReports": previous_state.get(
970
+ "allowAccessToFinancialReports"
971
+ ),
972
+ # BotRole-specific fields
973
+ "repeatUntilSuccessful": previous_state.get(
974
+ "repeatUntilSuccessful"
975
+ ),
976
+ "repeatInMinutes": previous_state.get("repeatInMinutes"),
977
+ "repeatInHours": previous_state.get("repeatInHours"),
978
+ "repeatInDays": previous_state.get("repeatInDays"),
979
+ "durationInMinutes": previous_state.get("durationInMinutes"),
980
+ "durationInHours": previous_state.get("durationInHours"),
981
+ "durationInDays": previous_state.get("durationInDays"),
982
+ },
983
+ )
984
+
985
+ try:
986
+ await client.delete(
987
+ "/role/{role_id}",
988
+ path_params={"role_id": role_id},
989
+ resource_type="role",
990
+ resource_id=role_id,
991
+ )
992
+
993
+ # Mark audit as success
994
+ await audit_logger.mark_success(
995
+ audit_id,
996
+ result={
997
+ "deleted": True,
998
+ "role_id": str(role_id),
999
+ },
1000
+ )
1001
+
1002
+ return {
1003
+ "deleted": True,
1004
+ "role_id": str(role_id), # Normalize to string for consistency
1005
+ "deleted_role": {
1006
+ "id": normalized_previous_state["id"],
1007
+ "name": normalized_previous_state["name"],
1008
+ "service_id": normalized_previous_state["service_id"],
1009
+ },
1010
+ "audit_id": audit_id,
1011
+ }
1012
+
1013
+ except BPAClientError as e:
1014
+ # Mark audit as failed
1015
+ await audit_logger.mark_failed(audit_id, str(e))
1016
+ raise translate_error(e, resource_type="role", resource_id=role_id)
1017
+
1018
+ except ToolError:
1019
+ raise
1020
+ except BPAClientError as e:
1021
+ raise translate_error(e, resource_type="role", resource_id=role_id)
1022
+
1023
+
1024
+ async def roleinstitution_create(
1025
+ role_id: str | int,
1026
+ institution_id: str,
1027
+ ) -> dict[str, Any]:
1028
+ """Assign institution to a role. Audited write operation. Required for publishing.
1029
+
1030
+ Args:
1031
+ role_id: Role to assign institution to.
1032
+ institution_id: Institution to assign.
1033
+
1034
+ Returns:
1035
+ dict with id, role_id, institution_id, audit_id.
1036
+ """
1037
+ # Pre-flight validation (no audit if these fail)
1038
+ if not role_id:
1039
+ raise ToolError(
1040
+ "Cannot create role institution: 'role_id' is required. "
1041
+ "Use 'role_list' with service_id to find valid role IDs."
1042
+ )
1043
+ if not institution_id:
1044
+ raise ToolError(
1045
+ "Cannot create role institution: 'institution_id' is required. "
1046
+ "Use 'institution_discover' to find valid institution IDs."
1047
+ )
1048
+
1049
+ # Get user email for audit
1050
+ try:
1051
+ user_email = get_current_user_email()
1052
+ except NotAuthenticatedError as e:
1053
+ raise ToolError(str(e))
1054
+
1055
+ audit_logger = AuditLogger()
1056
+
1057
+ try:
1058
+ async with BPAClient() as client:
1059
+ # Verify role exists (no audit if not found)
1060
+ try:
1061
+ await client.get(
1062
+ "/role/{role_id}",
1063
+ path_params={"role_id": role_id},
1064
+ resource_type="role",
1065
+ resource_id=role_id,
1066
+ )
1067
+ except BPANotFoundError:
1068
+ raise ToolError(
1069
+ f"Role '{role_id}' not found. "
1070
+ "Use 'role_list' with service_id to see available roles."
1071
+ )
1072
+
1073
+ # Create PENDING audit record
1074
+ audit_id = await audit_logger.record_pending(
1075
+ user_email=user_email,
1076
+ operation_type="link",
1077
+ object_type="role_institution",
1078
+ params={
1079
+ "role_id": str(role_id),
1080
+ "institution_id": institution_id,
1081
+ },
1082
+ )
1083
+
1084
+ # Execute API call - body is the raw institution_id string
1085
+ try:
1086
+ result = await client.post(
1087
+ "/role/{role_id}/role_institution",
1088
+ path_params={"role_id": role_id},
1089
+ content=institution_id, # Raw string body
1090
+ resource_type="role_institution",
1091
+ )
1092
+
1093
+ # Mark audit as success
1094
+ await audit_logger.mark_success(
1095
+ audit_id,
1096
+ result={
1097
+ "role_id": str(role_id),
1098
+ "institution_id": institution_id,
1099
+ },
1100
+ )
1101
+
1102
+ # Transform response
1103
+ return {
1104
+ "id": result.get("id") if isinstance(result, dict) else None,
1105
+ "role_id": str(role_id),
1106
+ "institution_id": institution_id,
1107
+ "audit_id": audit_id,
1108
+ }
1109
+
1110
+ except BPAClientError as e:
1111
+ await audit_logger.mark_failed(audit_id, str(e))
1112
+ raise translate_error(e, resource_type="role_institution")
1113
+
1114
+ except ToolError:
1115
+ raise
1116
+ except BPAClientError as e:
1117
+ raise translate_error(e, resource_type="role", resource_id=role_id)
1118
+
1119
+
1120
+ async def roleregistration_create(
1121
+ role_id: str | int,
1122
+ registration_id: str | int,
1123
+ ) -> dict[str, Any]:
1124
+ """Assign registration to a role. Audited write operation. Required for publishing.
1125
+
1126
+ Args:
1127
+ role_id: Role to assign registration to.
1128
+ registration_id: Registration to assign.
1129
+
1130
+ Returns:
1131
+ dict with id, role_id, registration_id, audit_id.
1132
+ """
1133
+ # Pre-flight validation (no audit if these fail)
1134
+ if not role_id:
1135
+ raise ToolError(
1136
+ "Cannot create role registration: 'role_id' is required. "
1137
+ "Use 'role_list' with service_id to find valid role IDs."
1138
+ )
1139
+ if not registration_id:
1140
+ raise ToolError(
1141
+ "Cannot create role registration: 'registration_id' is required. "
1142
+ "Use 'registration_list' with service_id to find valid registration IDs."
1143
+ )
1144
+
1145
+ # Get user email for audit
1146
+ try:
1147
+ user_email = get_current_user_email()
1148
+ except NotAuthenticatedError as e:
1149
+ raise ToolError(str(e))
1150
+
1151
+ audit_logger = AuditLogger()
1152
+
1153
+ try:
1154
+ async with BPAClient() as client:
1155
+ # Verify role exists (no audit if not found)
1156
+ try:
1157
+ await client.get(
1158
+ "/role/{role_id}",
1159
+ path_params={"role_id": role_id},
1160
+ resource_type="role",
1161
+ resource_id=role_id,
1162
+ )
1163
+ except BPANotFoundError:
1164
+ raise ToolError(
1165
+ f"Role '{role_id}' not found. "
1166
+ "Use 'role_list' with service_id to see available roles."
1167
+ )
1168
+
1169
+ # Create PENDING audit record
1170
+ audit_id = await audit_logger.record_pending(
1171
+ user_email=user_email,
1172
+ operation_type="link",
1173
+ object_type="role_registration",
1174
+ params={
1175
+ "role_id": str(role_id),
1176
+ "registration_id": str(registration_id),
1177
+ },
1178
+ )
1179
+
1180
+ # Execute API call - body is the raw registration_id string
1181
+ try:
1182
+ result = await client.post(
1183
+ "/role/{role_id}/role_registration",
1184
+ path_params={"role_id": role_id},
1185
+ content=str(
1186
+ registration_id
1187
+ ), # Raw string body like role_institution
1188
+ resource_type="role_registration",
1189
+ )
1190
+
1191
+ # Mark audit as success
1192
+ await audit_logger.mark_success(
1193
+ audit_id,
1194
+ result={
1195
+ "role_id": str(role_id),
1196
+ "registration_id": str(registration_id),
1197
+ },
1198
+ )
1199
+
1200
+ # Transform response
1201
+ return {
1202
+ "id": result.get("id") if isinstance(result, dict) else None,
1203
+ "role_id": str(role_id),
1204
+ "registration_id": str(registration_id),
1205
+ "audit_id": audit_id,
1206
+ }
1207
+
1208
+ except BPAClientError as e:
1209
+ await audit_logger.mark_failed(audit_id, str(e))
1210
+ raise translate_error(e, resource_type="role_registration")
1211
+
1212
+ except ToolError:
1213
+ raise
1214
+ except BPAClientError as e:
1215
+ raise translate_error(e, resource_type="role", resource_id=role_id)
1216
+
1217
+
1218
+ def register_role_tools(mcp: Any) -> None:
1219
+ """Register role tools with the MCP server.
1220
+
1221
+ Args:
1222
+ mcp: The FastMCP server instance.
1223
+ """
1224
+ # Read operations
1225
+ mcp.tool()(role_list)
1226
+ mcp.tool()(role_get)
1227
+ # Write operations (audit-before-write pattern)
1228
+ mcp.tool()(role_create)
1229
+ mcp.tool()(role_update)
1230
+ # Role assignment tools (required for publishing)
1231
+ mcp.tool()(roleinstitution_create)
1232
+ mcp.tool()(roleregistration_create)
1233
+ # NOTE: role_delete disabled due to Camunda server-side 404 error.
1234
+ # The BPA server returns "Camunda publish problem" when deleting roles.
1235
+ # Re-enable when the server-side issue is resolved.
1236
+ # mcp.tool()(role_delete)