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,1269 @@
|
|
|
1
|
+
"""MCP tools for BPA form operations.
|
|
2
|
+
|
|
3
|
+
This module provides tools for reading and manipulating Form.io forms in BPA services.
|
|
4
|
+
Forms include: applicant forms, guide forms, send file forms, and payment forms.
|
|
5
|
+
|
|
6
|
+
BPA uses a read-modify-write pattern for forms:
|
|
7
|
+
1. GET the complete form schema
|
|
8
|
+
2. Modify the components array
|
|
9
|
+
3. PUT the entire updated schema
|
|
10
|
+
|
|
11
|
+
Write operations follow the audit-before-write pattern:
|
|
12
|
+
1. Validate parameters (pre-flight, no audit record if validation fails)
|
|
13
|
+
2. Create PENDING audit record
|
|
14
|
+
3. Execute BPA API call
|
|
15
|
+
4. Update audit record to SUCCESS or FAILED
|
|
16
|
+
|
|
17
|
+
API Endpoints used:
|
|
18
|
+
- GET /service/{id}/applicant-form?reusable=false - Get applicant form
|
|
19
|
+
- GET /service/{id}/guide-form?reusable=false - Get guide form
|
|
20
|
+
- GET /service/{id}/send-file-form?reusable=false - Get send file form
|
|
21
|
+
- GET /service/{id}/payment-form?reusable=false - Get payment form
|
|
22
|
+
- PUT /applicant-form/{form_id} - Update applicant form
|
|
23
|
+
- PUT /guide-form/{form_id} - Update guide form
|
|
24
|
+
- PUT /send-file-form/{form_id} - Update send file form
|
|
25
|
+
- PUT /payment-form/{form_id} - Update payment form
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
34
|
+
|
|
35
|
+
from mcp_eregistrations_bpa.audit.context import (
|
|
36
|
+
NotAuthenticatedError,
|
|
37
|
+
get_current_user_email,
|
|
38
|
+
)
|
|
39
|
+
from mcp_eregistrations_bpa.audit.logger import AuditLogger
|
|
40
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
41
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
42
|
+
BPAClientError,
|
|
43
|
+
BPANotFoundError,
|
|
44
|
+
translate_error,
|
|
45
|
+
)
|
|
46
|
+
from mcp_eregistrations_bpa.tools.form_errors import FormErrorCode
|
|
47
|
+
from mcp_eregistrations_bpa.tools.formio_helpers import (
|
|
48
|
+
CONTAINER_TYPES,
|
|
49
|
+
find_component,
|
|
50
|
+
get_all_component_keys,
|
|
51
|
+
insert_component,
|
|
52
|
+
move_component,
|
|
53
|
+
remove_component,
|
|
54
|
+
update_component,
|
|
55
|
+
validate_component,
|
|
56
|
+
validate_component_key_unique,
|
|
57
|
+
)
|
|
58
|
+
from mcp_eregistrations_bpa.tools.large_response import large_response_handler
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"form_get",
|
|
62
|
+
"form_component_get",
|
|
63
|
+
"form_component_add",
|
|
64
|
+
"form_component_update",
|
|
65
|
+
"form_component_remove",
|
|
66
|
+
"form_component_move",
|
|
67
|
+
"form_update",
|
|
68
|
+
"register_form_tools",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Form type to endpoint mapping
|
|
73
|
+
FORM_TYPES = {
|
|
74
|
+
"applicant": {
|
|
75
|
+
"get_endpoint": "/service/{id}/applicant-form",
|
|
76
|
+
"put_endpoint": "/applicant-form/{form_id}",
|
|
77
|
+
"name": "Applicant Form",
|
|
78
|
+
},
|
|
79
|
+
"guide": {
|
|
80
|
+
"get_endpoint": "/service/{id}/guide-form",
|
|
81
|
+
"put_endpoint": "/guide-form/{form_id}",
|
|
82
|
+
"name": "Guide Form",
|
|
83
|
+
},
|
|
84
|
+
"send_file": {
|
|
85
|
+
"get_endpoint": "/service/{id}/send-file-form",
|
|
86
|
+
"put_endpoint": "/send-file-form/{form_id}",
|
|
87
|
+
"name": "Send File Form",
|
|
88
|
+
},
|
|
89
|
+
"payment": {
|
|
90
|
+
"get_endpoint": "/service/{id}/payment-form",
|
|
91
|
+
"put_endpoint": "/payment-form/{form_id}",
|
|
92
|
+
"name": "Payment Form",
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_form_type(form_type: str) -> dict[str, str]:
|
|
98
|
+
"""Validate form type and return endpoint config.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
form_type: Form type to validate.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Endpoint configuration dict.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ToolError: If form type is invalid.
|
|
108
|
+
"""
|
|
109
|
+
if form_type not in FORM_TYPES:
|
|
110
|
+
valid_types = ", ".join(sorted(FORM_TYPES.keys()))
|
|
111
|
+
raise ToolError(
|
|
112
|
+
f"[{FormErrorCode.INVALID_FORM_TYPE}] Invalid form type '{form_type}'. "
|
|
113
|
+
f"Valid types: {valid_types}"
|
|
114
|
+
)
|
|
115
|
+
return FORM_TYPES[form_type]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_form_schema(form_data: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
"""Parse formSchema from form data, handling string JSON.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
form_data: Form data from BPA API.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Parsed form schema dict.
|
|
126
|
+
"""
|
|
127
|
+
form_schema = form_data.get("formSchema", {})
|
|
128
|
+
if isinstance(form_schema, str):
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(form_schema)
|
|
131
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
return {}
|
|
134
|
+
return form_schema if isinstance(form_schema, dict) else {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_registration_name_map(
|
|
138
|
+
registrations: list[dict[str, Any]] | None,
|
|
139
|
+
) -> dict[str, str | None]:
|
|
140
|
+
"""Build UUID to name mapping from registrations list.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
registrations: List of registration objects with 'id' and 'name' fields.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Dict mapping registration UUIDs to their names.
|
|
147
|
+
"""
|
|
148
|
+
if not registrations or not isinstance(registrations, list):
|
|
149
|
+
return {}
|
|
150
|
+
return {
|
|
151
|
+
str(reg.get("id")): reg.get("name") for reg in registrations if reg.get("id")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_registration_uuids(
|
|
156
|
+
registrations: dict[str, Any] | None,
|
|
157
|
+
name_map: dict[str, str | None],
|
|
158
|
+
) -> dict[str, str | None] | None:
|
|
159
|
+
"""Resolve registration UUIDs to names.
|
|
160
|
+
|
|
161
|
+
Transforms registrations from {uuid: true} format to {uuid: "name"} format.
|
|
162
|
+
If a UUID cannot be resolved, the value is set to null.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
registrations: Component registrations dict ({uuid: true, ...}).
|
|
166
|
+
name_map: UUID to name mapping.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Dict with UUIDs as keys and registration names as values.
|
|
170
|
+
Returns None if registrations is None or empty.
|
|
171
|
+
"""
|
|
172
|
+
if not registrations or not isinstance(registrations, dict):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
resolved: dict[str, str | None] = {}
|
|
176
|
+
for uuid in registrations:
|
|
177
|
+
# Look up the name, defaulting to None if not found
|
|
178
|
+
resolved[uuid] = name_map.get(uuid)
|
|
179
|
+
return resolved
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _simplify_components(
|
|
183
|
+
components: list[dict[str, Any]],
|
|
184
|
+
path: list[str] | None = None,
|
|
185
|
+
registration_name_map: dict[str, str | None] | None = None,
|
|
186
|
+
) -> list[dict[str, Any]]:
|
|
187
|
+
"""Simplify components for display.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
components: Raw Form.io components.
|
|
191
|
+
path: Current nesting path.
|
|
192
|
+
registration_name_map: Optional UUID-to-name map for registration resolution.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Simplified component list.
|
|
196
|
+
"""
|
|
197
|
+
# Handle non-list components (BPA API may return int or other types)
|
|
198
|
+
if not isinstance(components, list):
|
|
199
|
+
return []
|
|
200
|
+
if path is None:
|
|
201
|
+
path = []
|
|
202
|
+
if registration_name_map is None:
|
|
203
|
+
registration_name_map = {}
|
|
204
|
+
|
|
205
|
+
result = []
|
|
206
|
+
for comp in components:
|
|
207
|
+
if not isinstance(comp, dict):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
key = comp.get("key")
|
|
211
|
+
if not key:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
comp_type = comp.get("type", "unknown")
|
|
215
|
+
simplified: dict[str, Any] = {
|
|
216
|
+
"key": key,
|
|
217
|
+
"type": comp_type,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if comp.get("label"):
|
|
221
|
+
simplified["label"] = comp["label"]
|
|
222
|
+
|
|
223
|
+
if path:
|
|
224
|
+
simplified["path"] = path
|
|
225
|
+
|
|
226
|
+
# Add validation info
|
|
227
|
+
validate = comp.get("validate", {})
|
|
228
|
+
if isinstance(validate, dict) and validate.get("required"):
|
|
229
|
+
simplified["required"] = True
|
|
230
|
+
|
|
231
|
+
# Add is_container flag for container types
|
|
232
|
+
if comp_type in CONTAINER_TYPES:
|
|
233
|
+
simplified["is_container"] = True
|
|
234
|
+
|
|
235
|
+
# Extract determinant_ids from Form.io component (always include, empty if none)
|
|
236
|
+
determinant_ids = comp.get("determinantIds", [])
|
|
237
|
+
if determinant_ids is None:
|
|
238
|
+
determinant_ids = []
|
|
239
|
+
elif not isinstance(determinant_ids, list):
|
|
240
|
+
determinant_ids = [determinant_ids] if determinant_ids else []
|
|
241
|
+
simplified["determinant_ids"] = determinant_ids
|
|
242
|
+
|
|
243
|
+
# Resolve registration UUIDs to names (Story 9.3)
|
|
244
|
+
raw_registrations = comp.get("registrations")
|
|
245
|
+
if raw_registrations:
|
|
246
|
+
resolved = _resolve_registration_uuids(
|
|
247
|
+
raw_registrations, registration_name_map
|
|
248
|
+
)
|
|
249
|
+
if resolved:
|
|
250
|
+
simplified["registrations"] = resolved
|
|
251
|
+
|
|
252
|
+
# Include component_action_id if present (Story 9.5)
|
|
253
|
+
if comp.get("componentActionId"):
|
|
254
|
+
simplified["component_action_id"] = comp["componentActionId"]
|
|
255
|
+
|
|
256
|
+
# Handle nested components (panels, fieldsets, editgrids, datagrids, etc.)
|
|
257
|
+
children_count = 0
|
|
258
|
+
nested = comp.get("components", [])
|
|
259
|
+
if nested and isinstance(nested, list):
|
|
260
|
+
children_count += len(nested)
|
|
261
|
+
result.extend(
|
|
262
|
+
_simplify_components(nested, path + [key], registration_name_map)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Handle columns (2-level: columns > cells > components)
|
|
266
|
+
columns = comp.get("columns", [])
|
|
267
|
+
if columns and isinstance(columns, list):
|
|
268
|
+
for col in columns:
|
|
269
|
+
if isinstance(col, dict):
|
|
270
|
+
col_comps = col.get("components", [])
|
|
271
|
+
if isinstance(col_comps, list):
|
|
272
|
+
children_count += len(col_comps)
|
|
273
|
+
reg_map = registration_name_map
|
|
274
|
+
result.extend(
|
|
275
|
+
_simplify_components(col_comps, path + [key], reg_map)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Handle table rows (rows[][] structure)
|
|
279
|
+
rows = comp.get("rows", [])
|
|
280
|
+
if rows and isinstance(rows, list):
|
|
281
|
+
for row in rows:
|
|
282
|
+
if isinstance(row, list):
|
|
283
|
+
for cell in row:
|
|
284
|
+
if isinstance(cell, dict):
|
|
285
|
+
cell_comps = cell.get("components", [])
|
|
286
|
+
if isinstance(cell_comps, list):
|
|
287
|
+
children_count += len(cell_comps)
|
|
288
|
+
result.extend(
|
|
289
|
+
_simplify_components(
|
|
290
|
+
cell_comps, path + [key], registration_name_map
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Add children_count for containers
|
|
295
|
+
if children_count > 0:
|
|
296
|
+
simplified["children_count"] = children_count
|
|
297
|
+
|
|
298
|
+
result.append(simplified)
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _get_form_data(
|
|
304
|
+
client: BPAClient,
|
|
305
|
+
service_id: str | int,
|
|
306
|
+
form_type: str,
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
"""Get raw form data from BPA.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
client: BPA client instance.
|
|
312
|
+
service_id: Service ID.
|
|
313
|
+
form_type: Type of form.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Raw form data from API.
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
ToolError: If form not found.
|
|
320
|
+
"""
|
|
321
|
+
config = _validate_form_type(form_type)
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
form_data = await client.get(
|
|
325
|
+
config["get_endpoint"],
|
|
326
|
+
path_params={"id": service_id},
|
|
327
|
+
params={"reusable": "false"},
|
|
328
|
+
resource_type="form",
|
|
329
|
+
resource_id=f"{service_id}/{form_type}",
|
|
330
|
+
)
|
|
331
|
+
except BPANotFoundError:
|
|
332
|
+
raise ToolError(
|
|
333
|
+
f"[{FormErrorCode.SERVICE_NOT_FOUND}] {config['name']} not found for "
|
|
334
|
+
f"service '{service_id}'. The service may not have this form type "
|
|
335
|
+
"configured."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return form_data
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _update_form_data(
|
|
342
|
+
client: BPAClient,
|
|
343
|
+
form_data: dict[str, Any],
|
|
344
|
+
form_type: str,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Update form data in BPA.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
client: BPA client instance.
|
|
350
|
+
form_data: Complete form data to PUT.
|
|
351
|
+
form_type: Type of form.
|
|
352
|
+
"""
|
|
353
|
+
config = _validate_form_type(form_type)
|
|
354
|
+
form_id = form_data.get("id")
|
|
355
|
+
|
|
356
|
+
await client.put(
|
|
357
|
+
config["put_endpoint"],
|
|
358
|
+
path_params={"form_id": form_id},
|
|
359
|
+
json=form_data,
|
|
360
|
+
resource_type="form",
|
|
361
|
+
resource_id=form_id,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# =============================================================================
|
|
366
|
+
# Read Operations
|
|
367
|
+
# =============================================================================
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@large_response_handler(
|
|
371
|
+
navigation={
|
|
372
|
+
"list_keys": "jq '.component_keys'",
|
|
373
|
+
"find_by_type": "jq '.components[] | select(.type == \"textfield\")'",
|
|
374
|
+
"find_by_key": "jq '.components[] | select(.key == \"fieldKey\")'",
|
|
375
|
+
"required_only": "jq '.components[] | select(.required == true)'",
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
async def form_get(
|
|
379
|
+
service_id: str | int,
|
|
380
|
+
form_type: str = "applicant",
|
|
381
|
+
include_raw: bool = False,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
"""Get form schema with simplified component list.
|
|
384
|
+
|
|
385
|
+
Large responses (>100KB) are saved to file with navigation hints.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
service_id: BPA service UUID.
|
|
389
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
390
|
+
include_raw: Include full raw_schema in response (default: False).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
dict with id, form_type, active, components, component_count, component_keys.
|
|
394
|
+
Includes raw_schema only when include_raw=True.
|
|
395
|
+
"""
|
|
396
|
+
config = _validate_form_type(form_type)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
async with BPAClient() as client:
|
|
400
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
401
|
+
|
|
402
|
+
# Fetch service registrations for UUID to name resolution (Story 9.3)
|
|
403
|
+
# This allows resolving registration UUIDs in form components to names
|
|
404
|
+
try:
|
|
405
|
+
service_data = await client.get(
|
|
406
|
+
"/service/{id}",
|
|
407
|
+
path_params={"id": service_id},
|
|
408
|
+
resource_type="service",
|
|
409
|
+
resource_id=str(service_id),
|
|
410
|
+
)
|
|
411
|
+
service_registrations = service_data.get("registrations", [])
|
|
412
|
+
except BPAClientError:
|
|
413
|
+
# If we can't fetch service data, continue without registration names
|
|
414
|
+
service_registrations = []
|
|
415
|
+
except ToolError:
|
|
416
|
+
raise
|
|
417
|
+
except BPAClientError as e:
|
|
418
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
419
|
+
|
|
420
|
+
# Build registration name map for UUID resolution
|
|
421
|
+
registration_name_map = _build_registration_name_map(service_registrations)
|
|
422
|
+
|
|
423
|
+
# Extract form schema
|
|
424
|
+
form_schema = _parse_form_schema(form_data)
|
|
425
|
+
components = form_schema.get("components", [])
|
|
426
|
+
# Handle BPA API returning non-list for components (e.g., integer count)
|
|
427
|
+
if not isinstance(components, list):
|
|
428
|
+
components = []
|
|
429
|
+
all_keys = get_all_component_keys(components)
|
|
430
|
+
simplified = _simplify_components(
|
|
431
|
+
components, registration_name_map=registration_name_map
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
result: dict[str, Any] = {
|
|
435
|
+
"id": form_data.get("id"),
|
|
436
|
+
"form_type": form_type,
|
|
437
|
+
"form_name": config["name"],
|
|
438
|
+
"service_id": service_id,
|
|
439
|
+
"active": form_data.get("active", True),
|
|
440
|
+
"components": simplified,
|
|
441
|
+
"component_count": len(all_keys),
|
|
442
|
+
"component_keys": sorted(all_keys),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
# Only include raw_schema when explicitly requested
|
|
446
|
+
if include_raw:
|
|
447
|
+
result["raw_schema"] = form_schema
|
|
448
|
+
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def form_component_get(
|
|
453
|
+
service_id: str | int,
|
|
454
|
+
component_key: str,
|
|
455
|
+
form_type: str = "applicant",
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
"""Get details of a form component, including nested components.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
service_id: BPA service UUID.
|
|
461
|
+
component_key: Component's key property.
|
|
462
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
dict with key, type, label, validate, data, determinant_ids, path, raw.
|
|
466
|
+
See docs/mcp-tools-guide.md for path hierarchy examples.
|
|
467
|
+
"""
|
|
468
|
+
config = _validate_form_type(form_type)
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
async with BPAClient() as client:
|
|
472
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
473
|
+
|
|
474
|
+
# Fetch service registrations for UUID to name resolution (Story 9.3)
|
|
475
|
+
try:
|
|
476
|
+
service_data = await client.get(
|
|
477
|
+
"/service/{id}",
|
|
478
|
+
path_params={"id": service_id},
|
|
479
|
+
resource_type="service",
|
|
480
|
+
resource_id=str(service_id),
|
|
481
|
+
)
|
|
482
|
+
service_registrations = service_data.get("registrations", [])
|
|
483
|
+
except BPAClientError:
|
|
484
|
+
# If we can't fetch service data, continue without registration names
|
|
485
|
+
service_registrations = []
|
|
486
|
+
except ToolError:
|
|
487
|
+
raise
|
|
488
|
+
except BPAClientError as e:
|
|
489
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
490
|
+
|
|
491
|
+
# Build registration name map for UUID resolution
|
|
492
|
+
registration_name_map = _build_registration_name_map(service_registrations)
|
|
493
|
+
|
|
494
|
+
# Extract form schema
|
|
495
|
+
form_schema = _parse_form_schema(form_data)
|
|
496
|
+
components = form_schema.get("components", [])
|
|
497
|
+
|
|
498
|
+
# Find component
|
|
499
|
+
result = find_component(components, component_key)
|
|
500
|
+
if result is None:
|
|
501
|
+
all_keys = get_all_component_keys(components)
|
|
502
|
+
key_preview = list(sorted(all_keys))[:10]
|
|
503
|
+
raise ToolError(
|
|
504
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component '{component_key}' not "
|
|
505
|
+
f"found in {config['name']}. Available keys include: "
|
|
506
|
+
f"{', '.join(key_preview)}... Use form_get to see all "
|
|
507
|
+
f"{len(all_keys)} components."
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
comp, path = result
|
|
511
|
+
|
|
512
|
+
# Build detailed response
|
|
513
|
+
response: dict[str, Any] = {
|
|
514
|
+
"key": comp.get("key"),
|
|
515
|
+
"type": comp.get("type"),
|
|
516
|
+
"label": comp.get("label"),
|
|
517
|
+
"form_type": form_type,
|
|
518
|
+
"service_id": service_id,
|
|
519
|
+
"path": path,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# Add validation info
|
|
523
|
+
validate = comp.get("validate", {})
|
|
524
|
+
if validate:
|
|
525
|
+
response["validate"] = validate
|
|
526
|
+
|
|
527
|
+
# Add data source info (for selects)
|
|
528
|
+
data = comp.get("data", {})
|
|
529
|
+
if data:
|
|
530
|
+
response["data"] = data
|
|
531
|
+
|
|
532
|
+
# Add BPA-specific properties
|
|
533
|
+
if comp.get("determinantIds"):
|
|
534
|
+
response["determinant_ids"] = comp["determinantIds"]
|
|
535
|
+
# Resolve registration UUIDs to names (Story 9.3)
|
|
536
|
+
raw_registrations = comp.get("registrations")
|
|
537
|
+
if raw_registrations:
|
|
538
|
+
resolved_registrations = _resolve_registration_uuids(
|
|
539
|
+
raw_registrations, registration_name_map
|
|
540
|
+
)
|
|
541
|
+
if resolved_registrations:
|
|
542
|
+
response["registrations"] = resolved_registrations
|
|
543
|
+
if comp.get("componentActionId"):
|
|
544
|
+
response["component_action_id"] = comp["componentActionId"]
|
|
545
|
+
if comp.get("componentFormulaId"):
|
|
546
|
+
response["component_formula_id"] = comp["componentFormulaId"]
|
|
547
|
+
|
|
548
|
+
# Add common properties
|
|
549
|
+
if comp.get("hidden"):
|
|
550
|
+
response["hidden"] = True
|
|
551
|
+
if comp.get("disabled"):
|
|
552
|
+
response["disabled"] = True
|
|
553
|
+
if comp.get("defaultValue") is not None:
|
|
554
|
+
response["default_value"] = comp["defaultValue"]
|
|
555
|
+
|
|
556
|
+
# Include raw component for advanced use
|
|
557
|
+
response["raw"] = comp
|
|
558
|
+
|
|
559
|
+
return response
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# =============================================================================
|
|
563
|
+
# Write Operations
|
|
564
|
+
# =============================================================================
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _validate_component_add_params(
|
|
568
|
+
component: dict[str, Any],
|
|
569
|
+
) -> list[str]:
|
|
570
|
+
"""Validate component for add operation.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
component: Component to validate.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
List of validation errors (empty if valid).
|
|
577
|
+
"""
|
|
578
|
+
return validate_component(component)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def form_component_add(
|
|
582
|
+
service_id: str | int,
|
|
583
|
+
component: dict[str, Any],
|
|
584
|
+
form_type: str = "applicant",
|
|
585
|
+
parent_key: str | None = None,
|
|
586
|
+
position: int | None = None,
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
"""Add component to form. Audited write operation.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
service_id: BPA service UUID.
|
|
592
|
+
component: Form.io component with key, type, label.
|
|
593
|
+
form_type: "applicant" (default), "guide", "send_file", "payment".
|
|
594
|
+
parent_key: Parent container key for nesting, or None for root.
|
|
595
|
+
position: Insert position (0-indexed), or None for end.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
dict with added, component_key, position, audit_id.
|
|
599
|
+
See docs/mcp-tools-guide.md for parent_key nesting examples.
|
|
600
|
+
"""
|
|
601
|
+
config = _validate_form_type(form_type)
|
|
602
|
+
|
|
603
|
+
# Pre-flight validation
|
|
604
|
+
errors = _validate_component_add_params(component)
|
|
605
|
+
if errors:
|
|
606
|
+
raise ToolError(
|
|
607
|
+
f"[{FormErrorCode.MISSING_REQUIRED_PROPERTY}] Invalid component: "
|
|
608
|
+
f"{'; '.join(errors)}. Ensure 'key', 'type', and 'label' are provided."
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
component_key = str(component.get("key", ""))
|
|
612
|
+
|
|
613
|
+
# Get authenticated user
|
|
614
|
+
try:
|
|
615
|
+
user_email = get_current_user_email()
|
|
616
|
+
except NotAuthenticatedError as e:
|
|
617
|
+
raise ToolError(str(e))
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
async with BPAClient() as client:
|
|
621
|
+
# Get current form
|
|
622
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
623
|
+
|
|
624
|
+
# Parse form schema
|
|
625
|
+
form_schema = _parse_form_schema(form_data)
|
|
626
|
+
components = form_schema.get("components", [])
|
|
627
|
+
|
|
628
|
+
# Check key uniqueness
|
|
629
|
+
if not validate_component_key_unique(components, component_key):
|
|
630
|
+
raise ToolError(
|
|
631
|
+
f"[{FormErrorCode.DUPLICATE_KEY}] Component key '{component_key}' "
|
|
632
|
+
"already exists in form. Use a unique key or use "
|
|
633
|
+
"form_component_update to modify existing."
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Validate parent if specified
|
|
637
|
+
if parent_key:
|
|
638
|
+
parent_result = find_component(components, parent_key)
|
|
639
|
+
if parent_result is None:
|
|
640
|
+
raise ToolError(
|
|
641
|
+
f"[{FormErrorCode.INVALID_PARENT}] Parent component "
|
|
642
|
+
f"'{parent_key}' not found. Use form_get to see "
|
|
643
|
+
"available components."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Create audit record BEFORE modification
|
|
647
|
+
audit_logger = AuditLogger()
|
|
648
|
+
audit_id = await audit_logger.record_pending(
|
|
649
|
+
user_email=user_email,
|
|
650
|
+
operation_type="create",
|
|
651
|
+
object_type="form_component",
|
|
652
|
+
params={
|
|
653
|
+
"service_id": str(service_id),
|
|
654
|
+
"form_type": form_type,
|
|
655
|
+
"form_id": form_data.get("id"),
|
|
656
|
+
"component_key": component_key,
|
|
657
|
+
"parent_key": parent_key,
|
|
658
|
+
"position": position,
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Save rollback state (entire form before modification)
|
|
663
|
+
await audit_logger.save_rollback_state(
|
|
664
|
+
audit_id=audit_id,
|
|
665
|
+
object_type="form",
|
|
666
|
+
object_id=str(form_data.get("id")),
|
|
667
|
+
previous_state=form_data,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
operation_error: Exception | None = None
|
|
671
|
+
try:
|
|
672
|
+
# Insert component
|
|
673
|
+
new_components = insert_component(
|
|
674
|
+
components, component, parent_key, position
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Update form schema
|
|
678
|
+
form_schema["components"] = new_components
|
|
679
|
+
form_data["formSchema"] = form_schema
|
|
680
|
+
|
|
681
|
+
# PUT updated form
|
|
682
|
+
await _update_form_data(client, form_data, form_type)
|
|
683
|
+
|
|
684
|
+
except ValueError as e:
|
|
685
|
+
operation_error = ToolError(f"[{FormErrorCode.INVALID_POSITION}] {e}")
|
|
686
|
+
except BPAClientError as e:
|
|
687
|
+
operation_error = translate_error(e, resource_type="form")
|
|
688
|
+
finally:
|
|
689
|
+
# Always update audit status, even if this fails
|
|
690
|
+
try:
|
|
691
|
+
if operation_error:
|
|
692
|
+
await audit_logger.mark_failed(audit_id, str(operation_error))
|
|
693
|
+
else:
|
|
694
|
+
await audit_logger.mark_success(
|
|
695
|
+
audit_id,
|
|
696
|
+
result={
|
|
697
|
+
"component_key": component_key,
|
|
698
|
+
"parent_key": parent_key,
|
|
699
|
+
"position": position,
|
|
700
|
+
},
|
|
701
|
+
)
|
|
702
|
+
except Exception:
|
|
703
|
+
pass # Don't mask the original error
|
|
704
|
+
|
|
705
|
+
if operation_error:
|
|
706
|
+
raise operation_error
|
|
707
|
+
|
|
708
|
+
except ToolError:
|
|
709
|
+
raise
|
|
710
|
+
except BPAClientError as e:
|
|
711
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
"added": True,
|
|
715
|
+
"component_key": component_key,
|
|
716
|
+
"component_type": component.get("type"),
|
|
717
|
+
"form_type": form_type,
|
|
718
|
+
"form_name": config["name"],
|
|
719
|
+
"service_id": service_id,
|
|
720
|
+
"parent_key": parent_key,
|
|
721
|
+
"position": position,
|
|
722
|
+
"audit_id": audit_id,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
async def form_component_update(
|
|
727
|
+
service_id: str | int,
|
|
728
|
+
component_key: str,
|
|
729
|
+
updates: dict[str, Any],
|
|
730
|
+
form_type: str = "applicant",
|
|
731
|
+
) -> dict[str, Any]:
|
|
732
|
+
"""Update component properties. Audited write operation.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
service_id: BPA service UUID.
|
|
736
|
+
component_key: Component to update.
|
|
737
|
+
updates: Properties to merge (e.g., {"label": "New", "hidden": True}).
|
|
738
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
dict with updated, component_key, updates_applied, previous_state, audit_id.
|
|
742
|
+
"""
|
|
743
|
+
config = _validate_form_type(form_type)
|
|
744
|
+
|
|
745
|
+
if not updates:
|
|
746
|
+
raise ToolError(
|
|
747
|
+
f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
|
|
748
|
+
"Specify properties to update."
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Prevent key changes
|
|
752
|
+
if "key" in updates and updates["key"] != component_key:
|
|
753
|
+
raise ToolError(
|
|
754
|
+
f"[{FormErrorCode.KEY_CHANGE_NOT_ALLOWED}] Cannot change component key. "
|
|
755
|
+
"To rename, remove and re-add the component."
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Get authenticated user
|
|
759
|
+
try:
|
|
760
|
+
user_email = get_current_user_email()
|
|
761
|
+
except NotAuthenticatedError as e:
|
|
762
|
+
raise ToolError(str(e))
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
async with BPAClient() as client:
|
|
766
|
+
# Get current form
|
|
767
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
768
|
+
|
|
769
|
+
# Parse form schema
|
|
770
|
+
form_schema = _parse_form_schema(form_data)
|
|
771
|
+
components = form_schema.get("components", [])
|
|
772
|
+
|
|
773
|
+
# Check component exists
|
|
774
|
+
found = find_component(components, component_key)
|
|
775
|
+
if found is None:
|
|
776
|
+
all_keys = get_all_component_keys(components)
|
|
777
|
+
raise ToolError(
|
|
778
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
|
|
779
|
+
f"'{component_key}' not found. Use form_get to see "
|
|
780
|
+
f"{len(all_keys)} available components."
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Create audit record
|
|
784
|
+
audit_logger = AuditLogger()
|
|
785
|
+
audit_id = await audit_logger.record_pending(
|
|
786
|
+
user_email=user_email,
|
|
787
|
+
operation_type="update",
|
|
788
|
+
object_type="form_component",
|
|
789
|
+
params={
|
|
790
|
+
"service_id": str(service_id),
|
|
791
|
+
"form_type": form_type,
|
|
792
|
+
"form_id": form_data.get("id"),
|
|
793
|
+
"component_key": component_key,
|
|
794
|
+
"updates": updates,
|
|
795
|
+
},
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
# Save rollback state
|
|
799
|
+
await audit_logger.save_rollback_state(
|
|
800
|
+
audit_id=audit_id,
|
|
801
|
+
object_type="form",
|
|
802
|
+
object_id=str(form_data.get("id")),
|
|
803
|
+
previous_state=form_data,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
operation_error: Exception | None = None
|
|
807
|
+
try:
|
|
808
|
+
# Update component
|
|
809
|
+
new_components, previous_state = update_component(
|
|
810
|
+
components, component_key, updates
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# Update form schema
|
|
814
|
+
form_schema["components"] = new_components
|
|
815
|
+
form_data["formSchema"] = form_schema
|
|
816
|
+
|
|
817
|
+
# PUT updated form
|
|
818
|
+
await _update_form_data(client, form_data, form_type)
|
|
819
|
+
|
|
820
|
+
except ValueError as e:
|
|
821
|
+
operation_error = ToolError(
|
|
822
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] {e}"
|
|
823
|
+
)
|
|
824
|
+
except BPAClientError as e:
|
|
825
|
+
operation_error = translate_error(e, resource_type="form")
|
|
826
|
+
finally:
|
|
827
|
+
# Always update audit status, even if this fails
|
|
828
|
+
try:
|
|
829
|
+
if operation_error:
|
|
830
|
+
await audit_logger.mark_failed(audit_id, str(operation_error))
|
|
831
|
+
else:
|
|
832
|
+
await audit_logger.mark_success(
|
|
833
|
+
audit_id,
|
|
834
|
+
result={
|
|
835
|
+
"component_key": component_key,
|
|
836
|
+
"updates_applied": list(updates.keys()),
|
|
837
|
+
},
|
|
838
|
+
)
|
|
839
|
+
except Exception:
|
|
840
|
+
pass # Don't mask the original error
|
|
841
|
+
|
|
842
|
+
if operation_error:
|
|
843
|
+
raise operation_error
|
|
844
|
+
|
|
845
|
+
except ToolError:
|
|
846
|
+
raise
|
|
847
|
+
except BPAClientError as e:
|
|
848
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
849
|
+
|
|
850
|
+
# Simplify previous state for response
|
|
851
|
+
prev_summary = {
|
|
852
|
+
"label": previous_state.get("label"),
|
|
853
|
+
"type": previous_state.get("type"),
|
|
854
|
+
}
|
|
855
|
+
if previous_state.get("validate"):
|
|
856
|
+
prev_summary["validate"] = previous_state["validate"]
|
|
857
|
+
if previous_state.get("hidden"):
|
|
858
|
+
prev_summary["hidden"] = previous_state["hidden"]
|
|
859
|
+
if previous_state.get("disabled"):
|
|
860
|
+
prev_summary["disabled"] = previous_state["disabled"]
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
"updated": True,
|
|
864
|
+
"component_key": component_key,
|
|
865
|
+
"form_type": form_type,
|
|
866
|
+
"form_name": config["name"],
|
|
867
|
+
"service_id": service_id,
|
|
868
|
+
"updates_applied": list(updates.keys()),
|
|
869
|
+
"previous_state": prev_summary,
|
|
870
|
+
"audit_id": audit_id,
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
async def form_component_remove(
|
|
875
|
+
service_id: str | int,
|
|
876
|
+
component_key: str,
|
|
877
|
+
form_type: str = "applicant",
|
|
878
|
+
) -> dict[str, Any]:
|
|
879
|
+
"""Remove component from form. Audited write operation.
|
|
880
|
+
|
|
881
|
+
Warning: May break determinant references. Check determinant_list first.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
service_id: BPA service UUID.
|
|
885
|
+
component_key: Component to remove.
|
|
886
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
dict with removed, component_key, deleted_component, audit_id.
|
|
890
|
+
"""
|
|
891
|
+
config = _validate_form_type(form_type)
|
|
892
|
+
|
|
893
|
+
# Get authenticated user
|
|
894
|
+
try:
|
|
895
|
+
user_email = get_current_user_email()
|
|
896
|
+
except NotAuthenticatedError as e:
|
|
897
|
+
raise ToolError(str(e))
|
|
898
|
+
|
|
899
|
+
try:
|
|
900
|
+
async with BPAClient() as client:
|
|
901
|
+
# Get current form
|
|
902
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
903
|
+
|
|
904
|
+
# Parse form schema
|
|
905
|
+
form_schema = _parse_form_schema(form_data)
|
|
906
|
+
components = form_schema.get("components", [])
|
|
907
|
+
|
|
908
|
+
# Check component exists
|
|
909
|
+
found = find_component(components, component_key)
|
|
910
|
+
if found is None:
|
|
911
|
+
all_keys = get_all_component_keys(components)
|
|
912
|
+
raise ToolError(
|
|
913
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
|
|
914
|
+
f"'{component_key}' not found. Use form_get to see "
|
|
915
|
+
f"{len(all_keys)} available components."
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
# Create audit record
|
|
919
|
+
audit_logger = AuditLogger()
|
|
920
|
+
audit_id = await audit_logger.record_pending(
|
|
921
|
+
user_email=user_email,
|
|
922
|
+
operation_type="delete",
|
|
923
|
+
object_type="form_component",
|
|
924
|
+
params={
|
|
925
|
+
"service_id": str(service_id),
|
|
926
|
+
"form_type": form_type,
|
|
927
|
+
"form_id": form_data.get("id"),
|
|
928
|
+
"component_key": component_key,
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# Save rollback state
|
|
933
|
+
await audit_logger.save_rollback_state(
|
|
934
|
+
audit_id=audit_id,
|
|
935
|
+
object_type="form",
|
|
936
|
+
object_id=str(form_data.get("id")),
|
|
937
|
+
previous_state=form_data,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
operation_error: Exception | None = None
|
|
941
|
+
try:
|
|
942
|
+
# Remove component
|
|
943
|
+
new_components, removed = remove_component(components, component_key)
|
|
944
|
+
|
|
945
|
+
# Update form schema
|
|
946
|
+
form_schema["components"] = new_components
|
|
947
|
+
form_data["formSchema"] = form_schema
|
|
948
|
+
|
|
949
|
+
# PUT updated form
|
|
950
|
+
await _update_form_data(client, form_data, form_type)
|
|
951
|
+
|
|
952
|
+
except ValueError as e:
|
|
953
|
+
operation_error = ToolError(
|
|
954
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] {e}"
|
|
955
|
+
)
|
|
956
|
+
except BPAClientError as e:
|
|
957
|
+
operation_error = translate_error(e, resource_type="form")
|
|
958
|
+
finally:
|
|
959
|
+
# Always update audit status, even if this fails
|
|
960
|
+
try:
|
|
961
|
+
if operation_error:
|
|
962
|
+
await audit_logger.mark_failed(audit_id, str(operation_error))
|
|
963
|
+
else:
|
|
964
|
+
await audit_logger.mark_success(
|
|
965
|
+
audit_id,
|
|
966
|
+
result={
|
|
967
|
+
"component_key": component_key,
|
|
968
|
+
"component_type": removed.get("type"),
|
|
969
|
+
},
|
|
970
|
+
)
|
|
971
|
+
except Exception:
|
|
972
|
+
pass # Don't mask the original error
|
|
973
|
+
|
|
974
|
+
if operation_error:
|
|
975
|
+
raise operation_error
|
|
976
|
+
|
|
977
|
+
except ToolError:
|
|
978
|
+
raise
|
|
979
|
+
except BPAClientError as e:
|
|
980
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
981
|
+
|
|
982
|
+
# Simplify removed component for response
|
|
983
|
+
removed_summary = {
|
|
984
|
+
"key": removed.get("key"),
|
|
985
|
+
"type": removed.get("type"),
|
|
986
|
+
"label": removed.get("label"),
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
"removed": True,
|
|
991
|
+
"component_key": component_key,
|
|
992
|
+
"form_type": form_type,
|
|
993
|
+
"form_name": config["name"],
|
|
994
|
+
"service_id": service_id,
|
|
995
|
+
"deleted_component": removed_summary,
|
|
996
|
+
"audit_id": audit_id,
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
async def form_component_move(
|
|
1001
|
+
service_id: str | int,
|
|
1002
|
+
component_key: str,
|
|
1003
|
+
new_parent_key: str | None = None,
|
|
1004
|
+
new_position: int | None = None,
|
|
1005
|
+
form_type: str = "applicant",
|
|
1006
|
+
) -> dict[str, Any]:
|
|
1007
|
+
"""Move component to new position. Audited write operation.
|
|
1008
|
+
|
|
1009
|
+
Args:
|
|
1010
|
+
service_id: BPA service UUID.
|
|
1011
|
+
component_key: Component to move.
|
|
1012
|
+
new_parent_key: Target parent container, or None for root.
|
|
1013
|
+
new_position: Position in target, or None for end.
|
|
1014
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
dict with moved, old_parent, old_position, new_parent, new_position, audit_id.
|
|
1018
|
+
"""
|
|
1019
|
+
config = _validate_form_type(form_type)
|
|
1020
|
+
|
|
1021
|
+
# Get authenticated user
|
|
1022
|
+
try:
|
|
1023
|
+
user_email = get_current_user_email()
|
|
1024
|
+
except NotAuthenticatedError as e:
|
|
1025
|
+
raise ToolError(str(e))
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
async with BPAClient() as client:
|
|
1029
|
+
# Get current form
|
|
1030
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
1031
|
+
|
|
1032
|
+
# Parse form schema
|
|
1033
|
+
form_schema = _parse_form_schema(form_data)
|
|
1034
|
+
components = form_schema.get("components", [])
|
|
1035
|
+
|
|
1036
|
+
# Check component exists
|
|
1037
|
+
found = find_component(components, component_key)
|
|
1038
|
+
if found is None:
|
|
1039
|
+
raise ToolError(
|
|
1040
|
+
f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
|
|
1041
|
+
f"'{component_key}' not found. Use form_get to see "
|
|
1042
|
+
"available components."
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
# Validate new parent if specified
|
|
1046
|
+
if new_parent_key:
|
|
1047
|
+
parent_result = find_component(components, new_parent_key)
|
|
1048
|
+
if parent_result is None:
|
|
1049
|
+
raise ToolError(
|
|
1050
|
+
f"[{FormErrorCode.INVALID_PARENT}] Target parent "
|
|
1051
|
+
f"'{new_parent_key}' not found. Use form_get to see "
|
|
1052
|
+
"available components."
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# Create audit record
|
|
1056
|
+
audit_logger = AuditLogger()
|
|
1057
|
+
audit_id = await audit_logger.record_pending(
|
|
1058
|
+
user_email=user_email,
|
|
1059
|
+
operation_type="update",
|
|
1060
|
+
object_type="form_component",
|
|
1061
|
+
params={
|
|
1062
|
+
"service_id": str(service_id),
|
|
1063
|
+
"form_type": form_type,
|
|
1064
|
+
"form_id": form_data.get("id"),
|
|
1065
|
+
"component_key": component_key,
|
|
1066
|
+
"new_parent_key": new_parent_key,
|
|
1067
|
+
"new_position": new_position,
|
|
1068
|
+
"operation": "move",
|
|
1069
|
+
},
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
# Save rollback state
|
|
1073
|
+
await audit_logger.save_rollback_state(
|
|
1074
|
+
audit_id=audit_id,
|
|
1075
|
+
object_type="form",
|
|
1076
|
+
object_id=str(form_data.get("id")),
|
|
1077
|
+
previous_state=form_data,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
operation_error: Exception | None = None
|
|
1081
|
+
try:
|
|
1082
|
+
# Move component
|
|
1083
|
+
new_components, move_info = move_component(
|
|
1084
|
+
components, component_key, new_parent_key, new_position
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# Update form schema
|
|
1088
|
+
form_schema["components"] = new_components
|
|
1089
|
+
form_data["formSchema"] = form_schema
|
|
1090
|
+
|
|
1091
|
+
# PUT updated form
|
|
1092
|
+
await _update_form_data(client, form_data, form_type)
|
|
1093
|
+
|
|
1094
|
+
except ValueError as e:
|
|
1095
|
+
operation_error = ToolError(f"[{FormErrorCode.INVALID_PARENT}] {e}")
|
|
1096
|
+
except BPAClientError as e:
|
|
1097
|
+
operation_error = translate_error(e, resource_type="form")
|
|
1098
|
+
finally:
|
|
1099
|
+
# Always update audit status, even if this fails
|
|
1100
|
+
try:
|
|
1101
|
+
if operation_error:
|
|
1102
|
+
await audit_logger.mark_failed(audit_id, str(operation_error))
|
|
1103
|
+
else:
|
|
1104
|
+
await audit_logger.mark_success(
|
|
1105
|
+
audit_id,
|
|
1106
|
+
result={
|
|
1107
|
+
"component_key": component_key,
|
|
1108
|
+
"move_info": move_info,
|
|
1109
|
+
},
|
|
1110
|
+
)
|
|
1111
|
+
except Exception:
|
|
1112
|
+
pass # Don't mask the original error
|
|
1113
|
+
|
|
1114
|
+
if operation_error:
|
|
1115
|
+
raise operation_error
|
|
1116
|
+
|
|
1117
|
+
except ToolError:
|
|
1118
|
+
raise
|
|
1119
|
+
except BPAClientError as e:
|
|
1120
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
"moved": True,
|
|
1124
|
+
"component_key": component_key,
|
|
1125
|
+
"form_type": form_type,
|
|
1126
|
+
"form_name": config["name"],
|
|
1127
|
+
"service_id": service_id,
|
|
1128
|
+
"old_parent": move_info.get("old_parent"),
|
|
1129
|
+
"old_position": move_info.get("old_position"),
|
|
1130
|
+
"new_parent": move_info.get("new_parent"),
|
|
1131
|
+
"new_position": move_info.get("new_position"),
|
|
1132
|
+
"audit_id": audit_id,
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
async def form_update(
|
|
1137
|
+
service_id: str | int,
|
|
1138
|
+
form_type: str = "applicant",
|
|
1139
|
+
components: list[dict[str, Any]] | None = None,
|
|
1140
|
+
active: bool | None = None,
|
|
1141
|
+
tutorials: dict[str, Any] | None = None,
|
|
1142
|
+
) -> dict[str, Any]:
|
|
1143
|
+
"""Update form schema. Audited write operation.
|
|
1144
|
+
|
|
1145
|
+
Warning: components replaces ALL existing. Use form_component_* for targeted
|
|
1146
|
+
changes.
|
|
1147
|
+
|
|
1148
|
+
Args:
|
|
1149
|
+
service_id: BPA service UUID.
|
|
1150
|
+
form_type: "applicant" (default), "guide", "send_file", or "payment".
|
|
1151
|
+
components: New components array (replaces existing).
|
|
1152
|
+
active: Set active status.
|
|
1153
|
+
tutorials: Update tutorials.
|
|
1154
|
+
|
|
1155
|
+
Returns:
|
|
1156
|
+
dict with updated, form_id, components_replaced, active_updated, audit_id.
|
|
1157
|
+
"""
|
|
1158
|
+
config = _validate_form_type(form_type)
|
|
1159
|
+
|
|
1160
|
+
if components is None and active is None and tutorials is None:
|
|
1161
|
+
raise ToolError(
|
|
1162
|
+
f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
|
|
1163
|
+
"Specify components, active, or tutorials to update."
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
# Get authenticated user
|
|
1167
|
+
try:
|
|
1168
|
+
user_email = get_current_user_email()
|
|
1169
|
+
except NotAuthenticatedError as e:
|
|
1170
|
+
raise ToolError(str(e))
|
|
1171
|
+
|
|
1172
|
+
try:
|
|
1173
|
+
async with BPAClient() as client:
|
|
1174
|
+
# Get current form
|
|
1175
|
+
form_data = await _get_form_data(client, service_id, form_type)
|
|
1176
|
+
|
|
1177
|
+
# Create audit record
|
|
1178
|
+
audit_logger = AuditLogger()
|
|
1179
|
+
audit_id = await audit_logger.record_pending(
|
|
1180
|
+
user_email=user_email,
|
|
1181
|
+
operation_type="update",
|
|
1182
|
+
object_type="form",
|
|
1183
|
+
params={
|
|
1184
|
+
"service_id": str(service_id),
|
|
1185
|
+
"form_type": form_type,
|
|
1186
|
+
"form_id": form_data.get("id"),
|
|
1187
|
+
"updates": {
|
|
1188
|
+
"components": components is not None,
|
|
1189
|
+
"active": active,
|
|
1190
|
+
"tutorials": tutorials is not None,
|
|
1191
|
+
},
|
|
1192
|
+
},
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
# Save rollback state
|
|
1196
|
+
await audit_logger.save_rollback_state(
|
|
1197
|
+
audit_id=audit_id,
|
|
1198
|
+
object_type="form",
|
|
1199
|
+
object_id=str(form_data.get("id")),
|
|
1200
|
+
previous_state=form_data,
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
try:
|
|
1204
|
+
# Apply updates
|
|
1205
|
+
if components is not None:
|
|
1206
|
+
form_schema = _parse_form_schema(form_data)
|
|
1207
|
+
form_schema["components"] = components
|
|
1208
|
+
form_data["formSchema"] = form_schema
|
|
1209
|
+
|
|
1210
|
+
if active is not None:
|
|
1211
|
+
form_data["active"] = active
|
|
1212
|
+
|
|
1213
|
+
if tutorials is not None:
|
|
1214
|
+
form_data["tutorials"] = tutorials
|
|
1215
|
+
|
|
1216
|
+
# PUT updated form
|
|
1217
|
+
await _update_form_data(client, form_data, form_type)
|
|
1218
|
+
|
|
1219
|
+
# Mark audit as success
|
|
1220
|
+
await audit_logger.mark_success(
|
|
1221
|
+
audit_id,
|
|
1222
|
+
result={
|
|
1223
|
+
"form_id": form_data.get("id"),
|
|
1224
|
+
"form_type": form_type,
|
|
1225
|
+
},
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
except BPAClientError as e:
|
|
1229
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1230
|
+
raise translate_error(e, resource_type="form")
|
|
1231
|
+
|
|
1232
|
+
except ToolError:
|
|
1233
|
+
raise
|
|
1234
|
+
except BPAClientError as e:
|
|
1235
|
+
raise translate_error(e, resource_type="form", resource_id=service_id)
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
"updated": True,
|
|
1239
|
+
"form_id": form_data.get("id"),
|
|
1240
|
+
"form_type": form_type,
|
|
1241
|
+
"form_name": config["name"],
|
|
1242
|
+
"service_id": service_id,
|
|
1243
|
+
"components_replaced": components is not None,
|
|
1244
|
+
"active_updated": active is not None,
|
|
1245
|
+
"tutorials_updated": tutorials is not None,
|
|
1246
|
+
"audit_id": audit_id,
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
# =============================================================================
|
|
1251
|
+
# Registration
|
|
1252
|
+
# =============================================================================
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def register_form_tools(mcp: Any) -> None:
|
|
1256
|
+
"""Register form tools with the MCP server.
|
|
1257
|
+
|
|
1258
|
+
Args:
|
|
1259
|
+
mcp: The FastMCP server instance.
|
|
1260
|
+
"""
|
|
1261
|
+
# Read operations
|
|
1262
|
+
mcp.tool()(form_get)
|
|
1263
|
+
mcp.tool()(form_component_get)
|
|
1264
|
+
# Write operations (audit-before-write pattern)
|
|
1265
|
+
mcp.tool()(form_component_add)
|
|
1266
|
+
mcp.tool()(form_component_update)
|
|
1267
|
+
mcp.tool()(form_component_remove)
|
|
1268
|
+
mcp.tool()(form_component_move)
|
|
1269
|
+
mcp.tool()(form_update)
|