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,897 @@
1
+ """MCP tools for BPA registration operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, deleting,
4
+ activating, and linking BPA registrations.
5
+
6
+ Write operations follow the audit-before-write pattern:
7
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
8
+ 2. Create PENDING audit record
9
+ 3. Execute BPA API call
10
+ 4. Update audit record to SUCCESS or FAILED
11
+
12
+ API Endpoints used:
13
+ - GET /registration - List all registrations
14
+ - GET /registration/{id} - Get registration by ID
15
+ - POST /registration - Create registration (with serviceId in body)
16
+ - DELETE /registration/{registration_id} - Delete registration
17
+ - POST /service_registration/{service_id}/{registration_id} - Link to service
18
+ - PUT /service/{service_id}/registration - Activate/deactivate registration
19
+
20
+ Note: The BPA API is service-centric. To get fields/determinants, use
21
+ the service-level endpoints (field_list, determinant_list with service_id).
22
+
23
+ Important: After creating a registration, you need TWO operations:
24
+ 1. serviceregistration_link - Links registration to service AND activates it
25
+ 2. registrationinstitution_create - Assigns institution (required for publishing)
26
+
27
+ Design principle: Elements are active by default upon creation/linking.
28
+ Use registration_activate with active=False to deactivate if needed.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import Any
34
+
35
+ from mcp.server.fastmcp.exceptions import ToolError
36
+
37
+ from mcp_eregistrations_bpa.audit.context import (
38
+ NotAuthenticatedError,
39
+ get_current_user_email,
40
+ )
41
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
42
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
43
+ from mcp_eregistrations_bpa.bpa_client.errors import (
44
+ BPAClientError,
45
+ BPANotFoundError,
46
+ translate_error,
47
+ )
48
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
49
+
50
+ __all__ = [
51
+ "registration_list",
52
+ "registration_get",
53
+ "registration_create",
54
+ "registration_delete",
55
+ "registration_activate",
56
+ "serviceregistration_link",
57
+ "register_registration_tools",
58
+ ]
59
+
60
+
61
+ @large_response_handler(
62
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
63
+ navigation={
64
+ "list_all": "jq '.registrations'",
65
+ "find_by_name": "jq '.registrations[] | select(.name | contains(\"search\"))'",
66
+ "find_by_service": "jq '.registrations[] | select(.service_id == \"UUID\")'",
67
+ },
68
+ )
69
+ async def registration_list(
70
+ service_id: str | int | None = None,
71
+ limit: int = 50,
72
+ offset: int = 0,
73
+ ) -> dict[str, Any]:
74
+ """List all BPA registrations.
75
+
76
+ Note: BPA extracts registrations from service response when filtering
77
+ (no server-side filter endpoint exists).
78
+ Large responses (>50KB) are saved to file with navigation hints.
79
+
80
+ Args:
81
+ service_id: Optional service ID to filter registrations by.
82
+ limit: Maximum number of registrations to return (default: 50).
83
+ offset: Number of registrations to skip (default: 0).
84
+
85
+ Returns:
86
+ dict with registrations (id, name, service_id), total, has_more.
87
+ """
88
+ # Normalize limit and offset
89
+ if limit <= 0:
90
+ limit = 50
91
+ if offset < 0:
92
+ offset = 0
93
+
94
+ try:
95
+ async with BPAClient() as client:
96
+ if service_id is not None:
97
+ # BPA API embeds registrations in service response
98
+ # Note: These are registration references (id, name only)
99
+ try:
100
+ service_data = await client.get(
101
+ "/service/{id}",
102
+ path_params={"id": service_id},
103
+ resource_type="service",
104
+ resource_id=service_id,
105
+ )
106
+ registrations_data = service_data.get("registrations", [])
107
+ except BPANotFoundError:
108
+ raise ToolError(
109
+ f"Service '{service_id}' not found. "
110
+ "Use 'service_list' to see available services."
111
+ )
112
+ else:
113
+ # Use global registration list
114
+ registrations_data = await client.get_list(
115
+ "/registration",
116
+ resource_type="registration",
117
+ )
118
+ except ToolError:
119
+ raise
120
+ except BPAClientError as e:
121
+ raise translate_error(e, resource_type="registration")
122
+
123
+ # Transform to consistent output format
124
+ registrations = []
125
+ for reg in registrations_data:
126
+ registrations.append(
127
+ {
128
+ "id": reg.get("id"),
129
+ "name": reg.get("name"),
130
+ "service_id": reg.get("serviceId")
131
+ if service_id is None
132
+ else service_id,
133
+ }
134
+ )
135
+
136
+ # Calculate total before pagination
137
+ total = len(registrations)
138
+
139
+ # Apply pagination
140
+ paginated_registrations = registrations[offset : offset + limit]
141
+
142
+ # Calculate has_more
143
+ has_more = (offset + limit) < total
144
+
145
+ return {
146
+ "registrations": paginated_registrations,
147
+ "total": total,
148
+ "has_more": has_more,
149
+ }
150
+
151
+
152
+ async def registration_get(registration_id: str | int) -> dict[str, Any]:
153
+ """Get details of a BPA registration by ID.
154
+
155
+ Returns registration details including linked service info.
156
+ Note: To get fields/determinants, use field_list(service_id) and
157
+ determinant_list(service_id) with the service_id from this registration.
158
+
159
+ Args:
160
+ registration_id: The unique identifier of the registration.
161
+
162
+ Returns:
163
+ dict: Registration details including:
164
+ - id, name, description, status
165
+ - service_id: The parent service ID
166
+ - service: Linked service summary (id, name)
167
+ """
168
+ try:
169
+ async with BPAClient() as client:
170
+ # Get registration details
171
+ try:
172
+ registration_data = await client.get(
173
+ "/registration/{id}",
174
+ path_params={"id": registration_id},
175
+ resource_type="registration",
176
+ resource_id=registration_id,
177
+ )
178
+ except BPANotFoundError:
179
+ raise ToolError(
180
+ f"Registration '{registration_id}' not found. "
181
+ "Use 'registration_list' to see available registrations."
182
+ )
183
+
184
+ # Get linked service info (if exists)
185
+ service_id = registration_data.get("serviceId")
186
+ service_data: dict[str, Any] = {}
187
+
188
+ # If serviceId not in registration response, search for parent service
189
+ # This mirrors the pattern in registration_delete
190
+ if not service_id:
191
+ services = await client.get_list("/service", resource_type="service")
192
+ for svc in services:
193
+ svc_id = svc.get("id")
194
+ try:
195
+ svc_detail = await client.get(
196
+ "/service/{id}",
197
+ path_params={"id": svc_id},
198
+ resource_type="service",
199
+ resource_id=svc_id,
200
+ )
201
+ for reg in svc_detail.get("registrations", []):
202
+ if str(reg.get("id")) == str(registration_id):
203
+ service_id = svc_id
204
+ service_data = svc_detail
205
+ break
206
+ except BPANotFoundError:
207
+ continue
208
+ if service_id:
209
+ break
210
+
211
+ # If serviceId was in registration response, fetch service details
212
+ if service_id and not service_data:
213
+ try:
214
+ service_data = await client.get(
215
+ "/service/{id}",
216
+ path_params={"id": service_id},
217
+ resource_type="service",
218
+ resource_id=service_id,
219
+ )
220
+ except BPANotFoundError:
221
+ pass
222
+ except ToolError:
223
+ raise
224
+ except BPAClientError as e:
225
+ raise translate_error(
226
+ e, resource_type="registration", resource_id=registration_id
227
+ )
228
+
229
+ service = {}
230
+ if service_data:
231
+ service = {
232
+ "id": service_data.get("id"),
233
+ "name": service_data.get("name"),
234
+ }
235
+
236
+ return {
237
+ "id": registration_data.get("id"),
238
+ "name": registration_data.get("name"),
239
+ "description": registration_data.get("description"),
240
+ "status": registration_data.get("status"),
241
+ "service_id": service_id,
242
+ "service": service,
243
+ }
244
+
245
+
246
+ def _validate_registration_create_params(
247
+ service_id: str | int,
248
+ name: str,
249
+ short_name: str,
250
+ key: str,
251
+ description: str | None,
252
+ ) -> dict[str, Any]:
253
+ """Validate registration_create parameters (pre-flight).
254
+
255
+ Returns validated params dict or raises ToolError if invalid.
256
+ No audit record is created for validation failures.
257
+
258
+ Args:
259
+ service_id: Parent service ID (required).
260
+ name: Registration name (required).
261
+ short_name: Short name for the registration (required).
262
+ key: Unique key identifier for the registration (required).
263
+ description: Registration description (optional).
264
+
265
+ Returns:
266
+ dict: Validated parameters ready for API call.
267
+
268
+ Raises:
269
+ ToolError: If validation fails.
270
+ """
271
+ errors = []
272
+
273
+ if not service_id:
274
+ errors.append("'service_id' is required")
275
+
276
+ if not name or not name.strip():
277
+ errors.append("'name' is required and cannot be empty")
278
+
279
+ if name and len(name.strip()) > 255:
280
+ errors.append("'name' must be 255 characters or less")
281
+
282
+ if not short_name or not short_name.strip():
283
+ errors.append("'short_name' is required and cannot be empty")
284
+
285
+ if short_name and len(short_name.strip()) > 50:
286
+ errors.append("'short_name' must be 50 characters or less")
287
+
288
+ if not key or not key.strip():
289
+ errors.append("'key' is required and cannot be empty")
290
+
291
+ if key and len(key.strip()) > 100:
292
+ errors.append("'key' must be 100 characters or less")
293
+
294
+ if errors:
295
+ error_msg = "; ".join(errors)
296
+ raise ToolError(
297
+ f"Cannot create registration: {error_msg}. "
298
+ "Provide valid 'service_id', 'name', 'short_name', and 'key' parameters."
299
+ )
300
+
301
+ params: dict[str, Any] = {
302
+ "name": name.strip(),
303
+ "shortName": short_name.strip(),
304
+ "key": key.strip(),
305
+ "serviceId": str(service_id),
306
+ "active": True, # Elements are active by default
307
+ "mandatorySelectedDefault": True,
308
+ }
309
+ if description:
310
+ params["description"] = description.strip()
311
+
312
+ return params
313
+
314
+
315
+ def _validate_registration_delete_params(
316
+ registration_id: str | int,
317
+ ) -> None:
318
+ """Validate registration_delete parameters (pre-flight).
319
+
320
+ Raises ToolError if validation fails.
321
+
322
+ Args:
323
+ registration_id: Registration ID to delete (required).
324
+
325
+ Raises:
326
+ ToolError: If validation fails.
327
+ """
328
+ if not registration_id:
329
+ raise ToolError(
330
+ "Cannot delete registration: 'registration_id' is required. "
331
+ "Use 'registration_list' to find valid registration IDs."
332
+ )
333
+
334
+
335
+ async def registration_create(
336
+ service_id: str | int,
337
+ name: str,
338
+ short_name: str,
339
+ key: str,
340
+ description: str | None = None,
341
+ ) -> dict[str, Any]:
342
+ """Create registration in a service. Audited write operation.
343
+
344
+ Requires registrationinstitution_create() for frontend visibility.
345
+
346
+ Args:
347
+ service_id: Parent service ID.
348
+ name: Registration name.
349
+ short_name: Short name.
350
+ key: Unique key identifier.
351
+ description: Optional description.
352
+
353
+ Returns:
354
+ dict with id, name, short_name, key, service_id, active, audit_id.
355
+ """
356
+ # Pre-flight validation (no audit record for validation failures)
357
+ validated_params = _validate_registration_create_params(
358
+ service_id, name, short_name, key, description
359
+ )
360
+
361
+ # Get authenticated user for audit (before any API calls)
362
+ try:
363
+ user_email = get_current_user_email()
364
+ except NotAuthenticatedError as e:
365
+ raise ToolError(str(e))
366
+
367
+ # Use single BPAClient connection for all operations
368
+ try:
369
+ async with BPAClient() as client:
370
+ # Verify parent service exists before creating audit record
371
+ try:
372
+ await client.get(
373
+ "/service/{id}",
374
+ path_params={"id": service_id},
375
+ resource_type="service",
376
+ resource_id=service_id,
377
+ )
378
+ except BPANotFoundError:
379
+ raise ToolError(
380
+ f"Cannot create registration: Service '{service_id}' not found. "
381
+ "Use 'service_list' to see available services."
382
+ )
383
+
384
+ # Create audit record BEFORE API call (audit-before-write pattern)
385
+ audit_logger = AuditLogger()
386
+ audit_id = await audit_logger.record_pending(
387
+ user_email=user_email,
388
+ operation_type="create",
389
+ object_type="registration",
390
+ params={
391
+ "service_id": str(service_id),
392
+ **validated_params,
393
+ },
394
+ )
395
+
396
+ try:
397
+ registration_data = await client.post(
398
+ "/registration",
399
+ json=validated_params,
400
+ resource_type="registration",
401
+ )
402
+
403
+ # Save rollback state (for create, save ID to enable deletion)
404
+ created_id = registration_data.get("id")
405
+ await audit_logger.save_rollback_state(
406
+ audit_id=audit_id,
407
+ object_type="registration",
408
+ object_id=str(created_id),
409
+ previous_state={
410
+ "id": created_id,
411
+ "name": registration_data.get("name"),
412
+ "shortName": registration_data.get("shortName"),
413
+ "key": registration_data.get("key"),
414
+ "description": registration_data.get("description"),
415
+ "serviceId": str(service_id),
416
+ "_operation": "create", # Marker for rollback to DELETE
417
+ },
418
+ )
419
+
420
+ # Mark audit as success
421
+ await audit_logger.mark_success(
422
+ audit_id,
423
+ result={
424
+ "registration_id": created_id,
425
+ "name": registration_data.get("name"),
426
+ "service_id": str(service_id),
427
+ },
428
+ )
429
+
430
+ return {
431
+ "id": created_id,
432
+ "name": registration_data.get("name"),
433
+ "short_name": registration_data.get("shortName"),
434
+ "key": registration_data.get("key"),
435
+ "description": registration_data.get("description"),
436
+ "status": registration_data.get("status"),
437
+ "active": registration_data.get("active", True),
438
+ "service_id": service_id,
439
+ "audit_id": audit_id,
440
+ }
441
+
442
+ except BPAClientError as e:
443
+ # Mark audit as failed
444
+ await audit_logger.mark_failed(audit_id, str(e))
445
+ raise translate_error(e, resource_type="registration")
446
+
447
+ except ToolError:
448
+ raise
449
+ except BPAClientError as e:
450
+ raise translate_error(e, resource_type="service", resource_id=service_id)
451
+
452
+
453
+ async def registration_delete(
454
+ registration_id: str | int,
455
+ ) -> dict[str, Any]:
456
+ """Delete a BPA registration. Audited write operation.
457
+
458
+ Note: BPA may return "Permission denied" due to server-side workflow permissions.
459
+
460
+ Args:
461
+ registration_id: Registration ID to delete.
462
+
463
+ Returns:
464
+ dict with deleted (bool), registration_id, deleted_registration, audit_id.
465
+ """
466
+ # Pre-flight validation (no audit record for validation failures)
467
+ _validate_registration_delete_params(registration_id)
468
+
469
+ # Get authenticated user for audit
470
+ try:
471
+ user_email = get_current_user_email()
472
+ except NotAuthenticatedError as e:
473
+ raise ToolError(str(e))
474
+
475
+ # Capture current state for rollback BEFORE making changes
476
+ try:
477
+ async with BPAClient() as client:
478
+ try:
479
+ previous_state = await client.get(
480
+ "/registration/{id}",
481
+ path_params={"id": registration_id},
482
+ resource_type="registration",
483
+ resource_id=registration_id,
484
+ )
485
+ except BPANotFoundError:
486
+ raise ToolError(
487
+ f"Registration '{registration_id}' not found. "
488
+ "Use 'registration_list' to see available registrations."
489
+ )
490
+
491
+ # BPA API doesn't return serviceId in registration GET response
492
+ # We need to find the parent service by checking all services
493
+ service_id = previous_state.get("serviceId")
494
+ if not service_id:
495
+ # Search for the service containing this registration
496
+ services = await client.get_list("/service", resource_type="service")
497
+ for svc in services:
498
+ svc_id = svc.get("id")
499
+ try:
500
+ svc_detail = await client.get(
501
+ "/service/{id}",
502
+ path_params={"id": svc_id},
503
+ resource_type="service",
504
+ resource_id=svc_id,
505
+ )
506
+ for reg in svc_detail.get("registrations", []):
507
+ if str(reg.get("id")) == str(registration_id):
508
+ service_id = svc_id
509
+ break
510
+ except BPANotFoundError:
511
+ continue
512
+ if service_id:
513
+ break
514
+
515
+ except ToolError:
516
+ raise
517
+ except BPAClientError as e:
518
+ raise translate_error(
519
+ e, resource_type="registration", resource_id=registration_id
520
+ )
521
+
522
+ # Build complete previous state for rollback (includes service_id)
523
+ rollback_previous_state = {
524
+ "id": previous_state.get("id"),
525
+ "name": previous_state.get("name"),
526
+ "shortName": previous_state.get("shortName"),
527
+ "key": previous_state.get("key"),
528
+ "description": previous_state.get("description"),
529
+ "serviceId": service_id, # Now we have the service_id
530
+ }
531
+
532
+ # Normalize previous_state to snake_case for response
533
+ normalized_previous_state = {
534
+ "id": previous_state.get("id"),
535
+ "name": previous_state.get("name"),
536
+ "description": previous_state.get("description"),
537
+ "service_id": service_id,
538
+ "status": previous_state.get("status"),
539
+ }
540
+
541
+ # Create audit record BEFORE API call (audit-before-write pattern)
542
+ audit_logger = AuditLogger()
543
+ audit_id = await audit_logger.record_pending(
544
+ user_email=user_email,
545
+ operation_type="delete",
546
+ object_type="registration",
547
+ object_id=str(registration_id),
548
+ params={"service_id": service_id}, # Include service_id for rollback
549
+ )
550
+
551
+ # Save rollback state for undo capability (recreate on rollback)
552
+ await audit_logger.save_rollback_state(
553
+ audit_id=audit_id,
554
+ object_type="registration",
555
+ object_id=str(registration_id),
556
+ previous_state=rollback_previous_state,
557
+ )
558
+
559
+ try:
560
+ async with BPAClient() as client:
561
+ await client.delete(
562
+ "/registration/{id}",
563
+ path_params={"id": registration_id},
564
+ resource_type="registration",
565
+ resource_id=registration_id,
566
+ )
567
+
568
+ # Mark audit as success
569
+ await audit_logger.mark_success(
570
+ audit_id,
571
+ result={
572
+ "deleted": True,
573
+ "registration_id": str(registration_id),
574
+ },
575
+ )
576
+
577
+ return {
578
+ "deleted": True,
579
+ "registration_id": registration_id,
580
+ "deleted_registration": {
581
+ "id": normalized_previous_state["id"],
582
+ "name": normalized_previous_state["name"],
583
+ "service_id": normalized_previous_state["service_id"],
584
+ },
585
+ "audit_id": audit_id,
586
+ }
587
+
588
+ except BPAClientError as e:
589
+ # Mark audit as failed
590
+ await audit_logger.mark_failed(audit_id, str(e))
591
+ raise translate_error(
592
+ e, resource_type="registration", resource_id=registration_id
593
+ )
594
+
595
+
596
+ async def registration_activate(
597
+ service_id: str | int,
598
+ registration_id: str | int,
599
+ active: bool = True,
600
+ ) -> dict[str, Any]:
601
+ """Activate or deactivate a registration. Audited write operation.
602
+
603
+ Args:
604
+ service_id: Service containing the registration.
605
+ registration_id: Registration to activate/deactivate.
606
+ active: True to activate, False to deactivate (default: True).
607
+
608
+ Returns:
609
+ dict with service_id, registration_id, registration_name, active, audit_id.
610
+ """
611
+ # Pre-flight validation
612
+ errors = []
613
+ if not service_id:
614
+ errors.append("'service_id' is required")
615
+ if not registration_id:
616
+ errors.append("'registration_id' is required")
617
+ if errors:
618
+ error_msg = "; ".join(errors)
619
+ raise ToolError(
620
+ f"Cannot activate/deactivate registration: {error_msg}. "
621
+ "Provide valid 'service_id' and 'registration_id' parameters."
622
+ )
623
+
624
+ # Get authenticated user for audit
625
+ try:
626
+ user_email = get_current_user_email()
627
+ except NotAuthenticatedError as e:
628
+ raise ToolError(str(e))
629
+
630
+ try:
631
+ async with BPAClient() as client:
632
+ # Verify service exists
633
+ try:
634
+ await client.get(
635
+ "/service/{id}",
636
+ path_params={"id": service_id},
637
+ resource_type="service",
638
+ resource_id=service_id,
639
+ )
640
+ except BPANotFoundError:
641
+ raise ToolError(
642
+ f"Cannot activate registration: Service '{service_id}' not found. "
643
+ "Use 'service_list' to see available services."
644
+ )
645
+
646
+ # Verify registration exists and get its details
647
+ try:
648
+ registration_data = await client.get(
649
+ "/registration/{id}",
650
+ path_params={"id": registration_id},
651
+ resource_type="registration",
652
+ resource_id=registration_id,
653
+ )
654
+ except BPANotFoundError:
655
+ raise ToolError(
656
+ f"Registration '{registration_id}' not found. "
657
+ "Use 'registration_list' to see available registrations."
658
+ )
659
+
660
+ # Create audit record BEFORE API call
661
+ audit_logger = AuditLogger()
662
+ audit_id = await audit_logger.record_pending(
663
+ user_email=user_email,
664
+ operation_type="update",
665
+ object_type="registration",
666
+ object_id=str(registration_id),
667
+ params={
668
+ "service_id": str(service_id),
669
+ "registration_id": str(registration_id),
670
+ "active": active,
671
+ },
672
+ )
673
+
674
+ try:
675
+ # Build payload - include registration data with active flag
676
+ payload = {
677
+ "id": str(registration_id),
678
+ "name": registration_data.get("name"),
679
+ "shortName": registration_data.get("shortName"),
680
+ "key": registration_data.get("key"),
681
+ "active": active,
682
+ }
683
+
684
+ # PUT /service/{service_id}/registration
685
+ await client.put(
686
+ "/service/{service_id}/registration",
687
+ path_params={"service_id": service_id},
688
+ json=payload,
689
+ resource_type="registration",
690
+ )
691
+
692
+ # Save rollback state
693
+ await audit_logger.save_rollback_state(
694
+ audit_id=audit_id,
695
+ object_type="registration",
696
+ object_id=str(registration_id),
697
+ previous_state={
698
+ "service_id": str(service_id),
699
+ "registration_id": str(registration_id),
700
+ "active": not active, # Previous state was opposite
701
+ "_operation": "activate",
702
+ },
703
+ )
704
+
705
+ # Mark audit as success
706
+ await audit_logger.mark_success(
707
+ audit_id,
708
+ result={
709
+ "service_id": str(service_id),
710
+ "registration_id": str(registration_id),
711
+ "active": active,
712
+ },
713
+ )
714
+
715
+ return {
716
+ "service_id": str(service_id),
717
+ "registration_id": str(registration_id),
718
+ "registration_name": registration_data.get("name"),
719
+ "active": active,
720
+ "audit_id": audit_id,
721
+ }
722
+
723
+ except BPAClientError as e:
724
+ await audit_logger.mark_failed(audit_id, str(e))
725
+ raise translate_error(e, resource_type="registration")
726
+
727
+ except ToolError:
728
+ raise
729
+ except BPAClientError as e:
730
+ raise translate_error(e, resource_type="registration")
731
+
732
+
733
+ async def serviceregistration_link(
734
+ service_id: str | int,
735
+ registration_id: str | int,
736
+ ) -> dict[str, Any]:
737
+ """Link registration to service and activate it. Audited write operation.
738
+
739
+ Makes registration appear in service's UI. Also use registrationinstitution_create
740
+ to assign institution (required for publishing).
741
+
742
+ Args:
743
+ service_id: Service to link registration to.
744
+ registration_id: Registration to link.
745
+
746
+ Returns:
747
+ dict with service_id, registration_id, service_name, registration_name,
748
+ linked, active, audit_id.
749
+ """
750
+ # Pre-flight validation
751
+ errors = []
752
+ if not service_id:
753
+ errors.append("'service_id' is required")
754
+ if not registration_id:
755
+ errors.append("'registration_id' is required")
756
+ if errors:
757
+ error_msg = "; ".join(errors)
758
+ raise ToolError(
759
+ f"Cannot link registration to service: {error_msg}. "
760
+ "Provide valid 'service_id' and 'registration_id' parameters."
761
+ )
762
+
763
+ # Get authenticated user for audit
764
+ try:
765
+ user_email = get_current_user_email()
766
+ except NotAuthenticatedError as e:
767
+ raise ToolError(str(e))
768
+
769
+ try:
770
+ async with BPAClient() as client:
771
+ # Verify service exists
772
+ try:
773
+ service_data = await client.get(
774
+ "/service/{id}",
775
+ path_params={"id": service_id},
776
+ resource_type="service",
777
+ resource_id=service_id,
778
+ )
779
+ except BPANotFoundError:
780
+ raise ToolError(
781
+ f"Cannot link registration: Service '{service_id}' not found. "
782
+ "Use 'service_list' to see available services."
783
+ )
784
+
785
+ # Verify registration exists
786
+ try:
787
+ registration_data = await client.get(
788
+ "/registration/{id}",
789
+ path_params={"id": registration_id},
790
+ resource_type="registration",
791
+ resource_id=registration_id,
792
+ )
793
+ except BPANotFoundError:
794
+ raise ToolError(
795
+ f"Registration '{registration_id}' not found. "
796
+ "Use 'registration_list' to see available registrations."
797
+ )
798
+
799
+ # Create audit record BEFORE API call
800
+ audit_logger = AuditLogger()
801
+ audit_id = await audit_logger.record_pending(
802
+ user_email=user_email,
803
+ operation_type="link",
804
+ object_type="service_registration",
805
+ params={
806
+ "service_id": str(service_id),
807
+ "registration_id": str(registration_id),
808
+ },
809
+ )
810
+
811
+ try:
812
+ # POST /service_registration/{service_id}/{registration_id}
813
+ await client.post(
814
+ "/service_registration/{service_id}/{registration_id}",
815
+ path_params={
816
+ "service_id": service_id,
817
+ "registration_id": registration_id,
818
+ },
819
+ json={"responseType": "text"},
820
+ resource_type="service_registration",
821
+ )
822
+
823
+ # Save rollback state (for unlink capability)
824
+ await audit_logger.save_rollback_state(
825
+ audit_id=audit_id,
826
+ object_type="service_registration",
827
+ object_id=f"{service_id}_{registration_id}",
828
+ previous_state={
829
+ "service_id": str(service_id),
830
+ "registration_id": str(registration_id),
831
+ "_operation": "link",
832
+ },
833
+ )
834
+
835
+ # Activate the registration by default after linking
836
+ # Build payload for activation
837
+ activate_payload = {
838
+ "id": str(registration_id),
839
+ "name": registration_data.get("name"),
840
+ "shortName": registration_data.get("shortName"),
841
+ "key": registration_data.get("key"),
842
+ "active": True,
843
+ }
844
+
845
+ # PUT /service/{service_id}/registration to activate
846
+ await client.put(
847
+ "/service/{service_id}/registration",
848
+ path_params={"service_id": service_id},
849
+ json=activate_payload,
850
+ resource_type="registration",
851
+ )
852
+
853
+ # Mark audit as success
854
+ await audit_logger.mark_success(
855
+ audit_id,
856
+ result={
857
+ "service_id": str(service_id),
858
+ "registration_id": str(registration_id),
859
+ "linked": True,
860
+ "active": True,
861
+ },
862
+ )
863
+
864
+ return {
865
+ "service_id": str(service_id),
866
+ "registration_id": str(registration_id),
867
+ "service_name": service_data.get("name"),
868
+ "registration_name": registration_data.get("name"),
869
+ "linked": True,
870
+ "active": True,
871
+ "audit_id": audit_id,
872
+ }
873
+
874
+ except BPAClientError as e:
875
+ await audit_logger.mark_failed(audit_id, str(e))
876
+ raise translate_error(e, resource_type="service_registration")
877
+
878
+ except ToolError:
879
+ raise
880
+ except BPAClientError as e:
881
+ raise translate_error(e, resource_type="service_registration")
882
+
883
+
884
+ def register_registration_tools(mcp: Any) -> None:
885
+ """Register registration tools with the MCP server.
886
+
887
+ Args:
888
+ mcp: The FastMCP server instance.
889
+ """
890
+ # Read operations
891
+ mcp.tool()(registration_list)
892
+ mcp.tool()(registration_get)
893
+ # Write operations (audit-before-write pattern)
894
+ mcp.tool()(registration_create)
895
+ mcp.tool()(registration_delete)
896
+ mcp.tool()(registration_activate)
897
+ mcp.tool()(serviceregistration_link)