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.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- 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)
|