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,765 @@
1
+ """MCP tools for BPA cost operations.
2
+
3
+ This module provides tools for creating, updating, and deleting BPA costs.
4
+ Costs define fees that applicants must pay for a registration.
5
+ Two cost types are supported: fixed costs and formula-based costs.
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
+ - POST /registration/{registration_id}/fixcost - Create fixed cost
15
+ - POST /registration/{registration_id}/formulacost - Create formula cost
16
+ - PUT /cost - Update cost
17
+ - DELETE /cost/{cost_id} - Delete cost
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from mcp.server.fastmcp.exceptions import ToolError
25
+
26
+ from mcp_eregistrations_bpa.audit.context import (
27
+ NotAuthenticatedError,
28
+ get_current_user_email,
29
+ )
30
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
31
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
32
+ from mcp_eregistrations_bpa.bpa_client.errors import (
33
+ BPAClientError,
34
+ BPANotFoundError,
35
+ translate_error,
36
+ )
37
+
38
+ __all__ = [
39
+ "cost_create_fixed",
40
+ "cost_create_formula",
41
+ "cost_update",
42
+ "cost_delete",
43
+ "register_cost_tools",
44
+ ]
45
+
46
+
47
+ def _transform_cost_response(
48
+ data: dict[str, Any], cost_type: str | None = None
49
+ ) -> dict[str, Any]:
50
+ """Transform cost API response from camelCase to snake_case.
51
+
52
+ Args:
53
+ data: Raw API response with camelCase keys.
54
+ cost_type: Optional cost type override ("fixed" or "formula").
55
+
56
+ Returns:
57
+ dict: Transformed response with snake_case keys.
58
+ """
59
+ result: dict[str, Any] = {
60
+ "id": data.get("id"),
61
+ "name": data.get("name"),
62
+ "description": data.get("additionalInformation") or data.get("description"),
63
+ "cost_type": cost_type or data.get("costType") or data.get("type"),
64
+ "registration_id": data.get("registrationId"),
65
+ }
66
+
67
+ # Include type-specific fields (API uses fixValue, currencyId, formulaCostItems)
68
+ if data.get("fixValue") is not None:
69
+ result["amount"] = data.get("fixValue")
70
+ elif data.get("amount") is not None:
71
+ result["amount"] = data.get("amount")
72
+ # Extract formula from formulaCostItems array or direct formula field
73
+ formula_items = data.get("formulaCostItems")
74
+ if formula_items and len(formula_items) > 0:
75
+ result["formula"] = formula_items[0].get("infixFormula")
76
+ elif data.get("formula"):
77
+ result["formula"] = data.get("formula")
78
+ if data.get("currencyId"):
79
+ result["currency"] = data.get("currencyId")
80
+ elif data.get("currency"):
81
+ result["currency"] = data.get("currency")
82
+ if data.get("variables"):
83
+ result["variables"] = data.get("variables")
84
+
85
+ return result
86
+
87
+
88
+ def _validate_cost_create_fixed_params(
89
+ registration_id: str | int,
90
+ name: str,
91
+ amount: float,
92
+ currency: str,
93
+ description: str | None,
94
+ ) -> dict[str, Any]:
95
+ """Validate cost_create_fixed parameters (pre-flight).
96
+
97
+ Returns validated params dict or raises ToolError if invalid.
98
+ No audit record is created for validation failures.
99
+
100
+ Args:
101
+ registration_id: Parent registration ID (required).
102
+ name: Cost name (required).
103
+ amount: Cost amount (required, must be >= 0).
104
+ currency: Currency code (required).
105
+ description: Cost description (optional).
106
+
107
+ Returns:
108
+ dict: Validated parameters ready for API call.
109
+
110
+ Raises:
111
+ ToolError: If validation fails.
112
+ """
113
+ errors = []
114
+
115
+ if not registration_id:
116
+ errors.append("'registration_id' is required")
117
+
118
+ if not name or not name.strip():
119
+ errors.append("'name' is required and cannot be empty")
120
+
121
+ if name and len(name.strip()) > 255:
122
+ errors.append("'name' must be 255 characters or less")
123
+
124
+ if amount is None:
125
+ errors.append("'amount' is required")
126
+ elif amount < 0:
127
+ errors.append("'amount' must be a non-negative number")
128
+
129
+ # Note: currency parameter is accepted but currently ignored
130
+ # BPA API expects currencyId as a database UUID (optional per API docs)
131
+
132
+ if errors:
133
+ error_msg = "; ".join(errors)
134
+ raise ToolError(f"Cannot create fixed cost: {error_msg}.")
135
+
136
+ # API uses fixValue (not amount), costType discriminator
137
+ # currencyId is optional per API docs - omitted as it requires UUID lookup
138
+ params: dict[str, Any] = {
139
+ "name": name.strip(),
140
+ "fixValue": amount,
141
+ "costType": "FIX",
142
+ }
143
+ if description:
144
+ params["additionalInformation"] = description.strip()
145
+
146
+ return params
147
+
148
+
149
+ async def cost_create_fixed(
150
+ registration_id: str | int,
151
+ name: str,
152
+ amount: float,
153
+ currency: str = "USD",
154
+ description: str | None = None,
155
+ ) -> dict[str, Any]:
156
+ """Create fixed cost for a registration. Audited write operation.
157
+
158
+ Args:
159
+ registration_id: Parent registration ID.
160
+ name: Cost name.
161
+ amount: Fixed amount (must be >= 0).
162
+ currency: Currency code (default: "USD").
163
+ description: Optional description.
164
+
165
+ Returns:
166
+ dict with id, name, amount, currency, cost_type, registration_id, audit_id.
167
+ """
168
+ # Pre-flight validation (no audit record for validation failures)
169
+ validated_params = _validate_cost_create_fixed_params(
170
+ registration_id, name, amount, currency, description
171
+ )
172
+
173
+ # Get authenticated user for audit (before any API calls)
174
+ try:
175
+ user_email = get_current_user_email()
176
+ except NotAuthenticatedError as e:
177
+ raise ToolError(str(e))
178
+
179
+ # Use single BPAClient connection for all operations
180
+ try:
181
+ async with BPAClient() as client:
182
+ # Verify parent registration exists before creating audit record
183
+ try:
184
+ await client.get(
185
+ "/registration/{registration_id}",
186
+ path_params={"registration_id": registration_id},
187
+ resource_type="registration",
188
+ resource_id=registration_id,
189
+ )
190
+ except BPANotFoundError:
191
+ raise ToolError(
192
+ f"Cannot create fixed cost: Registration '{registration_id}' "
193
+ "not found. Use 'registration_list' to see available registrations."
194
+ )
195
+
196
+ # Create audit record BEFORE API call (audit-before-write pattern)
197
+ audit_logger = AuditLogger()
198
+ audit_id = await audit_logger.record_pending(
199
+ user_email=user_email,
200
+ operation_type="create",
201
+ object_type="cost",
202
+ params={
203
+ "registration_id": str(registration_id),
204
+ "cost_type": "fixed",
205
+ **validated_params,
206
+ },
207
+ )
208
+
209
+ try:
210
+ cost_data = await client.post(
211
+ "/registration/{registration_id}/fixcost",
212
+ path_params={"registration_id": registration_id},
213
+ json=validated_params,
214
+ resource_type="cost",
215
+ )
216
+
217
+ # Save rollback state (for create, save ID to enable deletion)
218
+ created_id = cost_data.get("id")
219
+ await audit_logger.save_rollback_state(
220
+ audit_id=audit_id,
221
+ object_type="cost",
222
+ object_id=str(created_id),
223
+ previous_state={
224
+ "id": created_id,
225
+ "name": cost_data.get("name"),
226
+ "fixValue": cost_data.get("fixValue"),
227
+ "costType": "FIX",
228
+ "registrationId": str(registration_id),
229
+ "_operation": "create", # Marker for rollback to DELETE
230
+ },
231
+ )
232
+
233
+ # Mark audit as success
234
+ await audit_logger.mark_success(
235
+ audit_id,
236
+ result={
237
+ "cost_id": cost_data.get("id"),
238
+ "name": cost_data.get("name"),
239
+ "amount": validated_params["fixValue"],
240
+ "registration_id": str(registration_id),
241
+ },
242
+ )
243
+
244
+ result = _transform_cost_response(cost_data, cost_type="fixed")
245
+ # Explicitly set registration_id from function parameter
246
+ result["registration_id"] = registration_id
247
+ result["audit_id"] = audit_id
248
+ return result
249
+
250
+ except BPAClientError as e:
251
+ # Mark audit as failed
252
+ await audit_logger.mark_failed(audit_id, str(e))
253
+ raise translate_error(e, resource_type="cost")
254
+
255
+ except ToolError:
256
+ raise
257
+ except BPAClientError as e:
258
+ raise translate_error(
259
+ e, resource_type="registration", resource_id=registration_id
260
+ )
261
+
262
+
263
+ def _validate_cost_create_formula_params(
264
+ registration_id: str | int,
265
+ name: str,
266
+ formula: str,
267
+ variables: dict[str, Any] | None,
268
+ description: str | None,
269
+ ) -> dict[str, Any]:
270
+ """Validate cost_create_formula parameters (pre-flight).
271
+
272
+ Returns validated params dict or raises ToolError if invalid.
273
+ No audit record is created for validation failures.
274
+
275
+ Args:
276
+ registration_id: Parent registration ID (required).
277
+ name: Cost name (required).
278
+ formula: Cost formula expression (required).
279
+ variables: Variable definitions for formula (optional).
280
+ description: Cost description (optional).
281
+
282
+ Returns:
283
+ dict: Validated parameters ready for API call.
284
+
285
+ Raises:
286
+ ToolError: If validation fails.
287
+ """
288
+ errors = []
289
+
290
+ if not registration_id:
291
+ errors.append("'registration_id' is required")
292
+
293
+ if not name or not name.strip():
294
+ errors.append("'name' is required and cannot be empty")
295
+
296
+ if name and len(name.strip()) > 255:
297
+ errors.append("'name' must be 255 characters or less")
298
+
299
+ if not formula or not formula.strip():
300
+ errors.append("'formula' is required and cannot be empty")
301
+
302
+ if errors:
303
+ error_msg = "; ".join(errors)
304
+ raise ToolError(
305
+ f"Cannot create formula cost: {error_msg}. Check required fields."
306
+ )
307
+
308
+ # API expects formulaCostItems array with infixFormula, plus costType discriminator
309
+ params: dict[str, Any] = {
310
+ "name": name.strip(),
311
+ "costType": "FORMULA",
312
+ "formulaCostItems": [
313
+ {
314
+ "infixFormula": formula.strip(),
315
+ "sortOrder": 0,
316
+ }
317
+ ],
318
+ }
319
+ if description:
320
+ params["additionalInformation"] = description.strip()
321
+ # Note: variables parameter is currently not used by API
322
+
323
+ return params
324
+
325
+
326
+ async def cost_create_formula(
327
+ registration_id: str | int,
328
+ name: str,
329
+ formula: str,
330
+ variables: dict[str, Any] | None = None,
331
+ description: str | None = None,
332
+ ) -> dict[str, Any]:
333
+ """Create formula-based cost for a registration. Audited write operation.
334
+
335
+ Args:
336
+ registration_id: Parent registration ID.
337
+ name: Cost name.
338
+ formula: Formula expression for cost calculation.
339
+ variables: Optional variable definitions for formula.
340
+ description: Optional description.
341
+
342
+ Returns:
343
+ dict with id, name, formula, variables, cost_type, registration_id, audit_id.
344
+ """
345
+ # Pre-flight validation (no audit record for validation failures)
346
+ validated_params = _validate_cost_create_formula_params(
347
+ registration_id, name, formula, variables, description
348
+ )
349
+
350
+ # Get authenticated user for audit (before any API calls)
351
+ try:
352
+ user_email = get_current_user_email()
353
+ except NotAuthenticatedError as e:
354
+ raise ToolError(str(e))
355
+
356
+ # Use single BPAClient connection for all operations
357
+ try:
358
+ async with BPAClient() as client:
359
+ # Verify parent registration exists before creating audit record
360
+ try:
361
+ await client.get(
362
+ "/registration/{registration_id}",
363
+ path_params={"registration_id": registration_id},
364
+ resource_type="registration",
365
+ resource_id=registration_id,
366
+ )
367
+ except BPANotFoundError:
368
+ raise ToolError(
369
+ f"Cannot create formula cost: Registration '{registration_id}' "
370
+ "not found. Use 'registration_list' to see available registrations."
371
+ )
372
+
373
+ # Create audit record BEFORE API call (audit-before-write pattern)
374
+ audit_logger = AuditLogger()
375
+ audit_id = await audit_logger.record_pending(
376
+ user_email=user_email,
377
+ operation_type="create",
378
+ object_type="cost",
379
+ params={
380
+ "registration_id": str(registration_id),
381
+ "cost_type": "formula",
382
+ **validated_params,
383
+ },
384
+ )
385
+
386
+ try:
387
+ cost_data = await client.post(
388
+ "/registration/{registration_id}/formulacost",
389
+ path_params={"registration_id": registration_id},
390
+ json=validated_params,
391
+ resource_type="cost",
392
+ )
393
+
394
+ # Save rollback state (for create, save ID to enable deletion)
395
+ created_id = cost_data.get("id")
396
+ formula_str = validated_params["formulaCostItems"][0]["infixFormula"]
397
+ await audit_logger.save_rollback_state(
398
+ audit_id=audit_id,
399
+ object_type="cost",
400
+ object_id=str(created_id),
401
+ previous_state={
402
+ "id": created_id,
403
+ "name": cost_data.get("name"),
404
+ "formulaCostItems": validated_params["formulaCostItems"],
405
+ "costType": "FORMULA",
406
+ "registrationId": str(registration_id),
407
+ "_operation": "create", # Marker for rollback to DELETE
408
+ },
409
+ )
410
+
411
+ # Mark audit as success
412
+ await audit_logger.mark_success(
413
+ audit_id,
414
+ result={
415
+ "cost_id": cost_data.get("id"),
416
+ "name": cost_data.get("name"),
417
+ "formula": formula_str,
418
+ "registration_id": str(registration_id),
419
+ },
420
+ )
421
+
422
+ result = _transform_cost_response(cost_data, cost_type="formula")
423
+ # Explicitly set registration_id from function parameter
424
+ result["registration_id"] = registration_id
425
+ result["audit_id"] = audit_id
426
+ return result
427
+
428
+ except BPAClientError as e:
429
+ # Mark audit as failed
430
+ await audit_logger.mark_failed(audit_id, str(e))
431
+ raise translate_error(e, resource_type="cost")
432
+
433
+ except ToolError:
434
+ raise
435
+ except BPAClientError as e:
436
+ raise translate_error(
437
+ e, resource_type="registration", resource_id=registration_id
438
+ )
439
+
440
+
441
+ def _validate_cost_update_params(
442
+ cost_id: str | int,
443
+ cost_type: str,
444
+ name: str | None,
445
+ fix_value: float | None,
446
+ formula: str | None,
447
+ description: str | None,
448
+ ) -> dict[str, Any]:
449
+ """Validate cost_update parameters (pre-flight).
450
+
451
+ Returns validated params dict or raises ToolError if invalid.
452
+
453
+ Args:
454
+ cost_id: ID of cost to update (required).
455
+ cost_type: Type of cost ("FIX" or "FORMULA") (required).
456
+ name: New name (optional).
457
+ fix_value: New fixed value for fixed costs (optional).
458
+ formula: New formula for formula costs (optional).
459
+ description: New description (optional).
460
+
461
+ Returns:
462
+ dict: Validated parameters ready for API call.
463
+
464
+ Raises:
465
+ ToolError: If validation fails.
466
+ """
467
+ errors = []
468
+
469
+ if not cost_id:
470
+ errors.append("'cost_id' is required")
471
+
472
+ valid_cost_types = ["FIX", "FORMULA"]
473
+ if not cost_type:
474
+ errors.append("'cost_type' is required (FIX or FORMULA)")
475
+ elif cost_type.upper() not in valid_cost_types:
476
+ errors.append(f"'cost_type' must be one of: {', '.join(valid_cost_types)}")
477
+
478
+ if name is not None and not name.strip():
479
+ errors.append("'name' cannot be empty when provided")
480
+
481
+ if name and len(name.strip()) > 255:
482
+ errors.append("'name' must be 255 characters or less")
483
+
484
+ if fix_value is not None and fix_value < 0:
485
+ errors.append("'fix_value' must be a non-negative number when provided")
486
+
487
+ if formula is not None and not formula.strip():
488
+ errors.append("'formula' cannot be empty when provided")
489
+
490
+ # At least one field must be provided for update
491
+ if name is None and fix_value is None and formula is None and description is None:
492
+ errors.append(
493
+ "At least one field (name, fix_value, formula, description) "
494
+ "must be provided"
495
+ )
496
+
497
+ if errors:
498
+ error_msg = "; ".join(errors)
499
+ raise ToolError(f"Cannot update cost: {error_msg}. Check required fields.")
500
+
501
+ # Build params with correct API field names
502
+ normalized_cost_type = cost_type.upper()
503
+ params: dict[str, Any] = {
504
+ "id": cost_id,
505
+ "costType": normalized_cost_type,
506
+ }
507
+ if name is not None:
508
+ params["name"] = name.strip()
509
+ if fix_value is not None:
510
+ params["fixValue"] = fix_value
511
+ if formula is not None:
512
+ # API expects formulaCostItems array
513
+ params["formulaCostItems"] = [
514
+ {
515
+ "infixFormula": formula.strip(),
516
+ "sortOrder": 0,
517
+ }
518
+ ]
519
+ if description is not None:
520
+ params["additionalInformation"] = description.strip()
521
+
522
+ return params
523
+
524
+
525
+ async def cost_update(
526
+ cost_id: str | int,
527
+ cost_type: str,
528
+ name: str | None = None,
529
+ fix_value: float | None = None,
530
+ formula: str | None = None,
531
+ description: str | None = None,
532
+ ) -> dict[str, Any]:
533
+ """Update a cost. Audited write operation.
534
+
535
+ Args:
536
+ cost_id: Cost ID to update.
537
+ cost_type: "FIX" or "FORMULA".
538
+ name: New name (optional).
539
+ fix_value: New amount for FIX type (optional).
540
+ formula: New formula for FORMULA type (optional).
541
+ description: New description (optional).
542
+
543
+ Returns:
544
+ dict with id, name, cost_type, amount/formula, previous_state, audit_id.
545
+ """
546
+ # Pre-flight validation (no audit record for validation failures)
547
+ validated_params = _validate_cost_update_params(
548
+ cost_id, cost_type, name, fix_value, formula, description
549
+ )
550
+
551
+ # Get authenticated user for audit
552
+ try:
553
+ user_email = get_current_user_email()
554
+ except NotAuthenticatedError as e:
555
+ raise ToolError(str(e))
556
+
557
+ # Use single BPAClient connection for all operations
558
+ try:
559
+ async with BPAClient() as client:
560
+ # Capture current state for rollback before creating audit record
561
+ try:
562
+ current_state = await client.get(
563
+ "/cost/{cost_id}",
564
+ path_params={"cost_id": cost_id},
565
+ resource_type="cost",
566
+ resource_id=cost_id,
567
+ )
568
+ except BPANotFoundError:
569
+ raise ToolError(
570
+ f"Cost '{cost_id}' not found. "
571
+ "Verify the cost_id from 'cost_create_fixed' or "
572
+ "'cost_create_formula' response."
573
+ )
574
+
575
+ # Create audit record BEFORE API call (audit-before-write pattern)
576
+ audit_logger = AuditLogger()
577
+ audit_id = await audit_logger.record_pending(
578
+ user_email=user_email,
579
+ operation_type="update",
580
+ object_type="cost",
581
+ object_id=str(cost_id),
582
+ params={
583
+ "changes": {k: v for k, v in validated_params.items() if k != "id"},
584
+ },
585
+ )
586
+
587
+ try:
588
+ cost_data = await client.put(
589
+ "/cost",
590
+ json=validated_params,
591
+ resource_type="cost",
592
+ resource_id=cost_id,
593
+ )
594
+
595
+ # Save rollback state (previous state for restore)
596
+ await audit_logger.save_rollback_state(
597
+ audit_id=audit_id,
598
+ object_type="cost",
599
+ object_id=str(cost_id),
600
+ previous_state=current_state,
601
+ )
602
+
603
+ # Mark audit as success
604
+ await audit_logger.mark_success(
605
+ audit_id,
606
+ result={
607
+ "cost_id": cost_data.get("id"),
608
+ "name": cost_data.get("name"),
609
+ "changes_applied": {
610
+ k: v for k, v in validated_params.items() if k != "id"
611
+ },
612
+ },
613
+ )
614
+
615
+ result = _transform_cost_response(cost_data)
616
+ result["previous_state"] = _transform_cost_response(current_state)
617
+ result["audit_id"] = audit_id
618
+ return result
619
+
620
+ except BPANotFoundError:
621
+ # Mark audit as failed
622
+ await audit_logger.mark_failed(audit_id, f"Cost '{cost_id}' not found")
623
+ raise ToolError(
624
+ f"Cost '{cost_id}' not found. "
625
+ "Verify the cost_id from 'cost_create_fixed' or "
626
+ "'cost_create_formula' response."
627
+ )
628
+ except BPAClientError as e:
629
+ # Mark audit as failed
630
+ await audit_logger.mark_failed(audit_id, str(e))
631
+ raise translate_error(e, resource_type="cost", resource_id=cost_id)
632
+
633
+ except ToolError:
634
+ raise
635
+ except BPAClientError as e:
636
+ raise translate_error(e, resource_type="cost", resource_id=cost_id)
637
+
638
+
639
+ def _validate_cost_delete_params(cost_id: str | int) -> None:
640
+ """Validate cost_delete parameters (pre-flight).
641
+
642
+ Raises ToolError if validation fails.
643
+
644
+ Args:
645
+ cost_id: Cost ID to delete (required).
646
+
647
+ Raises:
648
+ ToolError: If validation fails.
649
+ """
650
+ if not cost_id:
651
+ raise ToolError(
652
+ "Cannot delete cost: 'cost_id' is required. "
653
+ "Cost IDs are returned from 'cost_create_fixed' or "
654
+ "'cost_create_formula'."
655
+ )
656
+
657
+
658
+ async def cost_delete(cost_id: str | int) -> dict[str, Any]:
659
+ """Delete a cost. Audited write operation.
660
+
661
+ Args:
662
+ cost_id: Cost ID to delete.
663
+
664
+ Returns:
665
+ dict with deleted (bool), cost_id, deleted_cost, audit_id.
666
+ """
667
+ # Pre-flight validation (no audit record for validation failures)
668
+ _validate_cost_delete_params(cost_id)
669
+
670
+ # Get authenticated user for audit
671
+ try:
672
+ user_email = get_current_user_email()
673
+ except NotAuthenticatedError as e:
674
+ raise ToolError(str(e))
675
+
676
+ # Use single BPAClient connection for all operations
677
+ try:
678
+ async with BPAClient() as client:
679
+ # Capture current state for rollback before creating audit record
680
+ try:
681
+ current_state = await client.get(
682
+ "/cost/{cost_id}",
683
+ path_params={"cost_id": cost_id},
684
+ resource_type="cost",
685
+ resource_id=cost_id,
686
+ )
687
+ except BPANotFoundError:
688
+ raise ToolError(
689
+ f"Cost '{cost_id}' not found. "
690
+ "Verify the cost_id from 'cost_create_fixed' or "
691
+ "'cost_create_formula' response."
692
+ )
693
+
694
+ # Create audit record BEFORE API call (audit-before-write pattern)
695
+ audit_logger = AuditLogger()
696
+ audit_id = await audit_logger.record_pending(
697
+ user_email=user_email,
698
+ operation_type="delete",
699
+ object_type="cost",
700
+ object_id=str(cost_id),
701
+ params={},
702
+ )
703
+
704
+ try:
705
+ await client.delete(
706
+ "/cost/{cost_id}",
707
+ path_params={"cost_id": cost_id},
708
+ resource_type="cost",
709
+ resource_id=cost_id,
710
+ )
711
+
712
+ # Save rollback state (full object for recreation)
713
+ await audit_logger.save_rollback_state(
714
+ audit_id=audit_id,
715
+ object_type="cost",
716
+ object_id=str(cost_id),
717
+ previous_state=current_state,
718
+ )
719
+
720
+ # Mark audit as success
721
+ await audit_logger.mark_success(
722
+ audit_id,
723
+ result={
724
+ "deleted": True,
725
+ "cost_id": str(cost_id),
726
+ },
727
+ )
728
+
729
+ return {
730
+ "deleted": True,
731
+ "cost_id": str(cost_id),
732
+ "deleted_cost": _transform_cost_response(current_state),
733
+ "audit_id": audit_id,
734
+ }
735
+
736
+ except BPANotFoundError:
737
+ # Mark audit as failed
738
+ await audit_logger.mark_failed(audit_id, f"Cost '{cost_id}' not found")
739
+ raise ToolError(
740
+ f"Cost '{cost_id}' not found. "
741
+ "Verify the cost_id from 'cost_create_fixed' or "
742
+ "'cost_create_formula' response."
743
+ )
744
+ except BPAClientError as e:
745
+ # Mark audit as failed
746
+ await audit_logger.mark_failed(audit_id, str(e))
747
+ raise translate_error(e, resource_type="cost", resource_id=cost_id)
748
+
749
+ except ToolError:
750
+ raise
751
+ except BPAClientError as e:
752
+ raise translate_error(e, resource_type="cost", resource_id=cost_id)
753
+
754
+
755
+ def register_cost_tools(mcp: Any) -> None:
756
+ """Register cost tools with the MCP server.
757
+
758
+ Args:
759
+ mcp: The FastMCP server instance.
760
+ """
761
+ # Write operations (audit-before-write pattern)
762
+ mcp.tool()(cost_create_fixed)
763
+ mcp.tool()(cost_create_formula)
764
+ mcp.tool()(cost_update)
765
+ mcp.tool()(cost_delete)