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,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)