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,674 @@
1
+ """MCP tools for BPA service operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, and updating BPA services.
4
+
5
+ Write operations follow the audit-before-write pattern:
6
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
7
+ 2. Create PENDING audit record
8
+ 3. Execute BPA API call
9
+ 4. Update audit record to SUCCESS or FAILED
10
+
11
+ API Endpoints used:
12
+ - GET /service - List all services
13
+ - GET /service/{id} - Get service by ID
14
+ - POST /service - Create new service
15
+ - PUT /service - Update service
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from mcp.server.fastmcp.exceptions import ToolError
23
+
24
+ from mcp_eregistrations_bpa.audit.context import (
25
+ NotAuthenticatedError,
26
+ get_current_user_email,
27
+ )
28
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
29
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
30
+ from mcp_eregistrations_bpa.bpa_client.errors import (
31
+ BPAClientError,
32
+ BPANotFoundError,
33
+ translate_error,
34
+ )
35
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
36
+
37
+ __all__ = [
38
+ "service_list",
39
+ "service_get",
40
+ "service_create",
41
+ "service_update",
42
+ "service_publish",
43
+ "service_activate",
44
+ "register_service_tools",
45
+ ]
46
+
47
+
48
+ @large_response_handler(
49
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
50
+ navigation={
51
+ "list_all": "jq '.services'",
52
+ "find_by_name": "jq '.services[] | select(.name | contains(\"search\"))'",
53
+ "find_by_status": "jq '.services[] | select(.status == \"ACTIVE\")'",
54
+ },
55
+ )
56
+ async def service_list(
57
+ limit: int = 50,
58
+ offset: int = 0,
59
+ ) -> dict[str, Any]:
60
+ """List all BPA services.
61
+
62
+ Returns all services the authenticated user has access to.
63
+ Each service includes id, name, status, and registration_count.
64
+ Large responses (>50KB) are saved to file with navigation hints.
65
+
66
+ Args:
67
+ limit: Maximum number of services to return (default: 50).
68
+ offset: Number of services to skip for pagination (default: 0).
69
+
70
+ Returns:
71
+ dict: List of services with total count.
72
+ - services: List of service objects
73
+ - total: Total number of services
74
+ - has_more: True if more services exist beyond current page
75
+ """
76
+ # Normalize pagination parameters
77
+ if limit <= 0:
78
+ limit = 50
79
+ if offset < 0:
80
+ offset = 0
81
+
82
+ try:
83
+ async with BPAClient() as client:
84
+ services_data = await client.get_list("/service", resource_type="service")
85
+ except BPAClientError as e:
86
+ raise translate_error(e, resource_type="service")
87
+
88
+ # Transform to consistent output format with registration_count
89
+ # Note: BPA list endpoint returns registrationCount=0 always. If registrations
90
+ # array is embedded (like in detail endpoint), use its length instead.
91
+ all_services = []
92
+ for svc in services_data:
93
+ # Prefer counting registrations array (if present) over BPA's registrationCount
94
+ registrations = svc.get("registrations")
95
+ if registrations is not None:
96
+ reg_count = len(registrations)
97
+ else:
98
+ reg_count = svc.get("registrationCount", 0)
99
+
100
+ all_services.append(
101
+ {
102
+ "id": svc.get("id"),
103
+ "name": svc.get("name"),
104
+ "status": svc.get("status"),
105
+ "registration_count": reg_count,
106
+ }
107
+ )
108
+
109
+ # Calculate total before pagination
110
+ total = len(all_services)
111
+
112
+ # Apply pagination
113
+ paginated_services = all_services[offset : offset + limit]
114
+
115
+ # Calculate has_more
116
+ has_more = (offset + limit) < total
117
+
118
+ return {
119
+ "services": paginated_services,
120
+ "total": total,
121
+ "has_more": has_more,
122
+ }
123
+
124
+
125
+ async def service_get(service_id: str | int) -> dict[str, Any]:
126
+ """Get details of a BPA service by ID.
127
+
128
+ Returns complete service details including registrations summary.
129
+
130
+ Args:
131
+ service_id: The unique identifier of the service.
132
+
133
+ Returns:
134
+ dict: Complete service details including:
135
+ - id, name, description, status, short_name
136
+ - registrations: List of registration summaries
137
+ - created_at, updated_at timestamps
138
+ """
139
+ try:
140
+ async with BPAClient() as client:
141
+ try:
142
+ service_data = await client.get(
143
+ "/service/{id}",
144
+ path_params={"id": service_id},
145
+ resource_type="service",
146
+ resource_id=service_id,
147
+ )
148
+ except BPANotFoundError:
149
+ raise ToolError(
150
+ f"Service '{service_id}' not found. "
151
+ "Use 'service_list' to see available services."
152
+ )
153
+
154
+ # Extract registrations embedded in service response
155
+ # Note: BPA API ignores serviceId param on /registration endpoint,
156
+ # returning ALL registrations globally. The correct approach is to
157
+ # use registrations already embedded in the service response.
158
+ registrations_data = service_data.get("registrations", [])
159
+ except ToolError:
160
+ # Re-raise ToolError (from BPANotFoundError handling above)
161
+ raise
162
+ except BPAClientError as e:
163
+ raise translate_error(e, resource_type="service", resource_id=service_id)
164
+
165
+ # Transform registrations to summary format (includes key per AC1)
166
+ registrations = [
167
+ {"id": reg.get("id"), "name": reg.get("name"), "key": reg.get("key")}
168
+ for reg in registrations_data
169
+ ]
170
+
171
+ return {
172
+ "id": service_data.get("id"),
173
+ "name": service_data.get("name"),
174
+ "description": service_data.get("description"),
175
+ "status": service_data.get("status"),
176
+ "short_name": service_data.get("shortName"),
177
+ "registrations": registrations,
178
+ "created_at": service_data.get("createdAt"),
179
+ "updated_at": service_data.get("updatedAt"),
180
+ }
181
+
182
+
183
+ def _validate_service_create_params(
184
+ name: str,
185
+ description: str | None,
186
+ short_name: str | None,
187
+ ) -> dict[str, Any]:
188
+ """Validate service_create parameters (pre-flight).
189
+
190
+ Returns validated params dict or raises ToolError if invalid.
191
+ No audit record is created for validation failures.
192
+
193
+ Args:
194
+ name: Service name (required).
195
+ description: Service description (optional).
196
+ short_name: Short name for the service (optional).
197
+
198
+ Returns:
199
+ dict: Validated parameters ready for API call.
200
+
201
+ Raises:
202
+ ToolError: If validation fails.
203
+ """
204
+ errors = []
205
+
206
+ if not name or not name.strip():
207
+ errors.append("'name' is required and cannot be empty")
208
+
209
+ if name and len(name.strip()) > 255:
210
+ errors.append("'name' must be 255 characters or less")
211
+
212
+ if short_name and len(short_name.strip()) > 50:
213
+ errors.append("'short_name' must be 50 characters or less")
214
+
215
+ if errors:
216
+ error_msg = "; ".join(errors)
217
+ raise ToolError(f"Cannot create service: {error_msg}. Check required fields.")
218
+
219
+ params: dict[str, Any] = {
220
+ "name": name.strip(),
221
+ "active": True, # Services are active by default
222
+ }
223
+ if description:
224
+ params["description"] = description.strip()
225
+ if short_name:
226
+ params["shortName"] = short_name.strip()
227
+
228
+ return params
229
+
230
+
231
+ async def service_create(
232
+ name: str,
233
+ description: str | None = None,
234
+ short_name: str | None = None,
235
+ ) -> dict[str, Any]:
236
+ """Create a new BPA service. Audited write operation.
237
+
238
+ Services are created as active by default.
239
+
240
+ Args:
241
+ name: Service name.
242
+ description: Optional description.
243
+ short_name: Optional short name.
244
+
245
+ Returns:
246
+ dict with id, name, description, status, short_name, active, created_at,
247
+ audit_id.
248
+ """
249
+ # Pre-flight validation (no audit record for validation failures)
250
+ validated_params = _validate_service_create_params(name, description, short_name)
251
+
252
+ # Get authenticated user for audit
253
+ try:
254
+ user_email = get_current_user_email()
255
+ except NotAuthenticatedError as e:
256
+ raise ToolError(str(e))
257
+
258
+ # Create audit record BEFORE API call (audit-before-write pattern)
259
+ audit_logger = AuditLogger()
260
+ audit_id = await audit_logger.record_pending(
261
+ user_email=user_email,
262
+ operation_type="create",
263
+ object_type="service",
264
+ params=validated_params,
265
+ )
266
+
267
+ try:
268
+ async with BPAClient() as client:
269
+ service_data = await client.post(
270
+ "/service",
271
+ json=validated_params,
272
+ resource_type="service",
273
+ )
274
+
275
+ # Save rollback state (for create, save ID to enable deletion on rollback)
276
+ created_id = service_data.get("id")
277
+ await audit_logger.save_rollback_state(
278
+ audit_id=audit_id,
279
+ object_type="service",
280
+ object_id=str(created_id),
281
+ previous_state={
282
+ "id": created_id,
283
+ "name": service_data.get("name"),
284
+ "description": service_data.get("description"),
285
+ "shortName": service_data.get("shortName"),
286
+ "_operation": "create", # Marker for rollback to know to DELETE
287
+ },
288
+ )
289
+
290
+ # Mark audit as success
291
+ await audit_logger.mark_success(
292
+ audit_id,
293
+ result={
294
+ "service_id": created_id,
295
+ "name": service_data.get("name"),
296
+ },
297
+ )
298
+
299
+ return {
300
+ "id": created_id,
301
+ "name": service_data.get("name"),
302
+ "description": service_data.get("description"),
303
+ "status": service_data.get("status"),
304
+ "short_name": service_data.get("shortName"),
305
+ "active": service_data.get("active", True),
306
+ "created_at": service_data.get("createdAt"),
307
+ "audit_id": audit_id,
308
+ }
309
+
310
+ except BPAClientError as e:
311
+ # Mark audit as failed
312
+ await audit_logger.mark_failed(audit_id, str(e))
313
+ raise translate_error(e, resource_type="service")
314
+
315
+
316
+ def _validate_service_update_params(
317
+ service_id: str | int,
318
+ name: str | None,
319
+ description: str | None,
320
+ short_name: str | None,
321
+ ) -> dict[str, Any]:
322
+ """Validate service_update parameters (pre-flight).
323
+
324
+ Returns validated params dict or raises ToolError if invalid.
325
+
326
+ Args:
327
+ service_id: ID of service to update (required).
328
+ name: New name (optional).
329
+ description: New description (optional).
330
+ short_name: New short name (optional).
331
+
332
+ Returns:
333
+ dict: Validated parameters ready for API call.
334
+
335
+ Raises:
336
+ ToolError: If validation fails.
337
+ """
338
+ errors = []
339
+
340
+ if not service_id:
341
+ errors.append("'service_id' is required")
342
+
343
+ if name is not None and not name.strip():
344
+ errors.append("'name' cannot be empty when provided")
345
+
346
+ if name and len(name.strip()) > 255:
347
+ errors.append("'name' must be 255 characters or less")
348
+
349
+ if short_name and len(short_name.strip()) > 50:
350
+ errors.append("'short_name' must be 50 characters or less")
351
+
352
+ # At least one field must be provided for update
353
+ if name is None and description is None and short_name is None:
354
+ errors.append(
355
+ "At least one field (name, description, short_name) must be provided"
356
+ )
357
+
358
+ if errors:
359
+ error_msg = "; ".join(errors)
360
+ raise ToolError(f"Cannot update service: {error_msg}. Check required fields.")
361
+
362
+ params: dict[str, Any] = {"id": service_id}
363
+ if name is not None:
364
+ params["name"] = name.strip()
365
+ if description is not None:
366
+ params["description"] = description.strip()
367
+ if short_name is not None:
368
+ params["shortName"] = short_name.strip()
369
+
370
+ return params
371
+
372
+
373
+ async def service_update(
374
+ service_id: str | int,
375
+ name: str | None = None,
376
+ description: str | None = None,
377
+ short_name: str | None = None,
378
+ ) -> dict[str, Any]:
379
+ """Update an existing BPA service. Audited write operation.
380
+
381
+ Args:
382
+ service_id: Service ID to update.
383
+ name: New name (optional).
384
+ description: New description (optional).
385
+ short_name: New short name (optional).
386
+
387
+ Returns:
388
+ dict with id, name, description, status, short_name, updated_at,
389
+ previous_state, audit_id.
390
+ """
391
+ # Pre-flight validation (no audit record for validation failures)
392
+ validated_params = _validate_service_update_params(
393
+ service_id, name, description, short_name
394
+ )
395
+
396
+ # Get authenticated user for audit
397
+ try:
398
+ user_email = get_current_user_email()
399
+ except NotAuthenticatedError as e:
400
+ raise ToolError(str(e))
401
+
402
+ # Capture current state for rollback BEFORE making changes
403
+ try:
404
+ async with BPAClient() as client:
405
+ try:
406
+ previous_state = await client.get(
407
+ "/service/{id}",
408
+ path_params={"id": service_id},
409
+ resource_type="service",
410
+ resource_id=service_id,
411
+ )
412
+ except BPANotFoundError:
413
+ raise ToolError(
414
+ f"Service '{service_id}' not found. "
415
+ "Use 'service_list' to see available services."
416
+ )
417
+ except ToolError:
418
+ raise
419
+ except BPAClientError as e:
420
+ raise translate_error(e, resource_type="service", resource_id=service_id)
421
+
422
+ # Create audit record BEFORE API call (audit-before-write pattern)
423
+ audit_logger = AuditLogger()
424
+ audit_id = await audit_logger.record_pending(
425
+ user_email=user_email,
426
+ operation_type="update",
427
+ object_type="service",
428
+ object_id=str(service_id),
429
+ params={
430
+ "changes": validated_params,
431
+ },
432
+ )
433
+
434
+ # Save rollback state for undo capability
435
+ await audit_logger.save_rollback_state(
436
+ audit_id=audit_id,
437
+ object_type="service",
438
+ object_id=str(service_id),
439
+ previous_state={
440
+ "id": previous_state.get("id"),
441
+ "name": previous_state.get("name"),
442
+ "description": previous_state.get("description"),
443
+ "shortName": previous_state.get("shortName"),
444
+ },
445
+ )
446
+
447
+ try:
448
+ async with BPAClient() as client:
449
+ # BPA API requires name in PUT body - merge previous state with changes
450
+ put_body = {
451
+ "id": service_id,
452
+ "name": validated_params.get("name", previous_state.get("name")),
453
+ }
454
+ if "description" in validated_params:
455
+ put_body["description"] = validated_params["description"]
456
+ elif previous_state.get("description"):
457
+ put_body["description"] = previous_state["description"]
458
+ if "shortName" in validated_params:
459
+ put_body["shortName"] = validated_params["shortName"]
460
+ elif previous_state.get("shortName"):
461
+ put_body["shortName"] = previous_state["shortName"]
462
+
463
+ service_data = await client.put(
464
+ "/service",
465
+ json=put_body,
466
+ resource_type="service",
467
+ resource_id=service_id,
468
+ )
469
+
470
+ # Mark audit as success
471
+ await audit_logger.mark_success(
472
+ audit_id,
473
+ result={
474
+ "service_id": service_data.get("id"),
475
+ "name": service_data.get("name"),
476
+ "changes_applied": {
477
+ k: v for k, v in validated_params.items() if k != "id"
478
+ },
479
+ },
480
+ )
481
+
482
+ return {
483
+ "id": service_data.get("id"),
484
+ "name": service_data.get("name"),
485
+ "description": service_data.get("description"),
486
+ "status": service_data.get("status"),
487
+ "short_name": service_data.get("shortName"),
488
+ "updated_at": service_data.get("updatedAt"),
489
+ "previous_state": {
490
+ "name": previous_state.get("name"),
491
+ "description": previous_state.get("description"),
492
+ "short_name": previous_state.get("shortName"),
493
+ },
494
+ "audit_id": audit_id,
495
+ }
496
+
497
+ except BPAClientError as e:
498
+ # Mark audit as failed
499
+ await audit_logger.mark_failed(audit_id, str(e))
500
+ raise translate_error(e, resource_type="service", resource_id=service_id)
501
+
502
+
503
+ async def service_publish(service_id: str | int) -> dict[str, Any]:
504
+ """Publish a BPA service to make it visible in the frontend.
505
+
506
+ Audited write operation.
507
+
508
+ Args:
509
+ service_id: Service ID to publish.
510
+
511
+ Returns:
512
+ dict with service_id, published (bool), audit_id.
513
+ """
514
+ # Pre-flight validation
515
+ if not service_id:
516
+ raise ToolError("'service_id' is required. Provide the service ID to publish.")
517
+
518
+ # Get authenticated user for audit
519
+ try:
520
+ user_email = get_current_user_email()
521
+ except NotAuthenticatedError as e:
522
+ raise ToolError(str(e))
523
+
524
+ # Verify service exists
525
+ try:
526
+ async with BPAClient() as client:
527
+ try:
528
+ await client.get(
529
+ "/service/{id}",
530
+ path_params={"id": service_id},
531
+ resource_type="service",
532
+ resource_id=service_id,
533
+ )
534
+ except BPANotFoundError:
535
+ raise ToolError(
536
+ f"Service '{service_id}' not found. "
537
+ "Use 'service_list' to see available services."
538
+ )
539
+ except ToolError:
540
+ raise
541
+ except BPAClientError as e:
542
+ raise translate_error(e, resource_type="service", resource_id=service_id)
543
+
544
+ # Create audit record BEFORE API call
545
+ audit_logger = AuditLogger()
546
+ audit_id = await audit_logger.record_pending(
547
+ user_email=user_email,
548
+ operation_type="update",
549
+ object_type="service",
550
+ object_id=str(service_id),
551
+ params={"action": "publish"},
552
+ )
553
+
554
+ try:
555
+ async with BPAClient() as client:
556
+ await client.post(
557
+ "/service/{id}/publish",
558
+ path_params={"id": service_id},
559
+ json={}, # Empty body required by BPA API
560
+ resource_type="service",
561
+ )
562
+
563
+ # Mark audit as success
564
+ await audit_logger.mark_success(
565
+ audit_id,
566
+ result={"service_id": service_id, "action": "published"},
567
+ )
568
+
569
+ return {
570
+ "service_id": str(service_id),
571
+ "published": True,
572
+ "audit_id": audit_id,
573
+ }
574
+
575
+ except BPAClientError as e:
576
+ await audit_logger.mark_failed(audit_id, str(e))
577
+ raise translate_error(e, resource_type="service", resource_id=service_id)
578
+
579
+
580
+ async def service_activate(
581
+ service_id: str | int,
582
+ active: bool = True,
583
+ ) -> dict[str, Any]:
584
+ """Activate or deactivate a BPA service. Audited write operation.
585
+
586
+ Args:
587
+ service_id: Service ID to activate/deactivate.
588
+ active: True to activate, False to deactivate (default: True).
589
+
590
+ Returns:
591
+ dict with service_id, active (bool), audit_id.
592
+ """
593
+ # Pre-flight validation
594
+ if not service_id:
595
+ raise ToolError(
596
+ "'service_id' is required. Provide the service ID to activate/deactivate."
597
+ )
598
+
599
+ # Get authenticated user for audit
600
+ try:
601
+ user_email = get_current_user_email()
602
+ except NotAuthenticatedError as e:
603
+ raise ToolError(str(e))
604
+
605
+ # Verify service exists
606
+ try:
607
+ async with BPAClient() as client:
608
+ try:
609
+ await client.get(
610
+ "/service/{id}",
611
+ path_params={"id": service_id},
612
+ resource_type="service",
613
+ resource_id=service_id,
614
+ )
615
+ except BPANotFoundError:
616
+ raise ToolError(
617
+ f"Service '{service_id}' not found. "
618
+ "Use 'service_list' to see available services."
619
+ )
620
+ except ToolError:
621
+ raise
622
+ except BPAClientError as e:
623
+ raise translate_error(e, resource_type="service", resource_id=service_id)
624
+
625
+ # Create audit record BEFORE API call
626
+ audit_logger = AuditLogger()
627
+ audit_id = await audit_logger.record_pending(
628
+ user_email=user_email,
629
+ operation_type="update",
630
+ object_type="service",
631
+ object_id=str(service_id),
632
+ params={"action": "activate", "active": active},
633
+ )
634
+
635
+ try:
636
+ async with BPAClient() as client:
637
+ await client.put(
638
+ "/service/{service_id}/activate/{active}",
639
+ path_params={"service_id": service_id, "active": str(active).lower()},
640
+ resource_type="service",
641
+ resource_id=service_id,
642
+ )
643
+
644
+ # Mark audit as success
645
+ await audit_logger.mark_success(
646
+ audit_id,
647
+ result={"service_id": service_id, "active": active},
648
+ )
649
+
650
+ return {
651
+ "service_id": str(service_id),
652
+ "active": active,
653
+ "audit_id": audit_id,
654
+ }
655
+
656
+ except BPAClientError as e:
657
+ await audit_logger.mark_failed(audit_id, str(e))
658
+ raise translate_error(e, resource_type="service", resource_id=service_id)
659
+
660
+
661
+ def register_service_tools(mcp: Any) -> None:
662
+ """Register service tools with the MCP server.
663
+
664
+ Args:
665
+ mcp: The FastMCP server instance.
666
+ """
667
+ # Read operations
668
+ mcp.tool()(service_list)
669
+ mcp.tool()(service_get)
670
+ # Write operations (audit-before-write pattern)
671
+ mcp.tool()(service_create)
672
+ mcp.tool()(service_update)
673
+ mcp.tool()(service_publish)
674
+ mcp.tool()(service_activate)