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,899 @@
1
+ """MCP tools for BPA service export operations.
2
+
3
+ This module provides tools for exporting complete BPA service definitions.
4
+
5
+ The export endpoint returns large JSON payloads (5-15MB) containing:
6
+ - Service metadata
7
+ - Registrations, determinants, roles, bots
8
+ - Form definitions (Form.io schemas)
9
+ - Translations, tutorials, catalogs
10
+
11
+ For large exports (>5MB), the data is saved to a file and a summary is returned.
12
+
13
+ API Endpoint used:
14
+ - POST /download_service/{service_id} - Export complete service definition
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import tempfile
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from mcp.server.fastmcp import Context
26
+ from mcp.server.fastmcp.exceptions import ToolError
27
+
28
+ # Note: ServerSession moved out of TYPE_CHECKING for runtime type annotation evaluation
29
+ from mcp.server.session import ServerSession
30
+
31
+ from mcp_eregistrations_bpa.audit.context import (
32
+ NotAuthenticatedError,
33
+ get_current_user_email,
34
+ )
35
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
36
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
37
+ from mcp_eregistrations_bpa.bpa_client.errors import (
38
+ BPAClientError,
39
+ BPANotFoundError,
40
+ translate_error,
41
+ )
42
+ from mcp_eregistrations_bpa.tools.yaml_transformer import (
43
+ TransformationError,
44
+ YAMLTransformer,
45
+ normalize_export_data,
46
+ )
47
+
48
+ __all__ = [
49
+ "service_export_raw",
50
+ "service_to_yaml",
51
+ "service_copy",
52
+ "register_export_tools",
53
+ "_safe_list_count",
54
+ ]
55
+
56
+ # Threshold for saving to file instead of returning inline (5MB for JSON)
57
+ LARGE_EXPORT_THRESHOLD_BYTES = 5 * 1024 * 1024
58
+
59
+ # Threshold for YAML output (500KB)
60
+ LARGE_YAML_THRESHOLD_BYTES = 500 * 1024
61
+
62
+
63
+ def _safe_list_count(value: Any) -> int:
64
+ """Safely count items in a list, handling None and non-list values.
65
+
66
+ Args:
67
+ value: The value to count (expected to be a list, but handles edge cases).
68
+
69
+ Returns:
70
+ int: Count of items if value is a non-empty list, otherwise 0.
71
+ """
72
+ if value is None:
73
+ return 0
74
+ if not isinstance(value, list):
75
+ return 0
76
+ return len(value)
77
+
78
+
79
+ def _generate_export_summary(
80
+ data: dict[str, Any],
81
+ size_bytes: int,
82
+ *,
83
+ include_forms: bool = True,
84
+ include_translations: bool = True,
85
+ include_catalogs: bool = True,
86
+ ) -> dict[str, Any]:
87
+ """Generate a summary of the exported service.
88
+
89
+ Args:
90
+ data: The complete export data.
91
+ size_bytes: Size of the export in bytes.
92
+ include_forms: Whether forms were included in export.
93
+ include_translations: Whether translations were included in export.
94
+ include_catalogs: Whether catalogs were included in export.
95
+
96
+ Returns:
97
+ dict: Summary statistics about the export.
98
+ """
99
+ # Normalize export data to handle both live API and test mock structures
100
+ data = normalize_export_data(data)
101
+
102
+ # Count entities using safe counting that handles None, missing keys, non-lists
103
+ registration_count = _safe_list_count(data.get("registrations"))
104
+ determinant_count = _safe_list_count(data.get("determinants"))
105
+ role_count = _safe_list_count(data.get("roles"))
106
+ bot_count = _safe_list_count(data.get("bots"))
107
+
108
+ # Count fields from forms
109
+ field_count = 0
110
+ for form_key in ["applicantForm", "guideForm", "sendFileForm", "paymentForm"]:
111
+ form = data.get(form_key)
112
+ if form and isinstance(form, dict):
113
+ form_schema = form.get("formSchema")
114
+ if form_schema and isinstance(form_schema, dict):
115
+ components = form_schema.get("components", [])
116
+ field_count += _count_form_components(components)
117
+
118
+ # Build excluded sections list
119
+ excluded_sections: list[str] = []
120
+ if not include_forms:
121
+ excluded_sections.append("forms")
122
+ if not include_translations:
123
+ excluded_sections.append("translations")
124
+ if not include_catalogs:
125
+ excluded_sections.append("catalogs")
126
+
127
+ summary: dict[str, Any] = {
128
+ "service_name": data.get("name", "Unknown"),
129
+ "service_id": data.get("id"),
130
+ "registration_count": registration_count,
131
+ "determinant_count": determinant_count,
132
+ "role_count": role_count,
133
+ "bot_count": bot_count,
134
+ "field_count": field_count,
135
+ "size_bytes": size_bytes,
136
+ "size_mb": round(size_bytes / (1024 * 1024), 2),
137
+ }
138
+
139
+ # Add selection info if any sections were excluded
140
+ if excluded_sections:
141
+ summary["excluded_sections"] = excluded_sections
142
+
143
+ return summary
144
+
145
+
146
+ def _count_form_components(components: list[Any]) -> int:
147
+ """Recursively count form components (fields).
148
+
149
+ Args:
150
+ components: List of Form.io components.
151
+
152
+ Returns:
153
+ int: Total count of form components.
154
+ """
155
+ count = 0
156
+ for comp in components:
157
+ if not isinstance(comp, dict):
158
+ continue
159
+ # Count this component if it has a key (it's a field)
160
+ if comp.get("key"):
161
+ count += 1
162
+ # Recursively count nested components
163
+ nested = comp.get("components", [])
164
+ if isinstance(nested, list):
165
+ count += _count_form_components(nested)
166
+ # Handle columns layout
167
+ columns = comp.get("columns", [])
168
+ if isinstance(columns, list):
169
+ for col in columns:
170
+ if isinstance(col, dict):
171
+ col_comps = col.get("components", [])
172
+ if isinstance(col_comps, list):
173
+ count += _count_form_components(col_comps)
174
+ return count
175
+
176
+
177
+ def _save_export_to_file(
178
+ data: dict[str, Any],
179
+ service_id: str,
180
+ ) -> str:
181
+ """Save export data to a temporary file.
182
+
183
+ Args:
184
+ data: The export data to save.
185
+ service_id: The service ID for the filename.
186
+
187
+ Returns:
188
+ str: Path to the saved file.
189
+ """
190
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
191
+ filename = f"bpa_export_{service_id}_{timestamp}.json"
192
+
193
+ # Use system temp directory
194
+ temp_dir = Path(tempfile.gettempdir())
195
+ file_path = temp_dir / filename
196
+
197
+ with open(file_path, "w", encoding="utf-8") as f:
198
+ json.dump(data, f, ensure_ascii=False, indent=2)
199
+
200
+ return str(file_path)
201
+
202
+
203
+ async def service_export_raw(
204
+ service_id: str | int,
205
+ include_all: bool = True,
206
+ include_forms: bool = True,
207
+ include_translations: bool = True,
208
+ include_catalogs: bool = True,
209
+ catalog_ids: list[str] | None = None,
210
+ ctx: Context[ServerSession, None] | None = None,
211
+ ) -> dict[str, Any]:
212
+ """Export complete service definition as raw JSON.
213
+
214
+ Large exports (>5MB) are saved to file; path returned in response.
215
+
216
+ Args:
217
+ service_id: BPA service UUID.
218
+ include_all: Include all components (default True).
219
+ include_forms: Include form schemas (default True).
220
+ include_translations: Include translations (default True).
221
+ include_catalogs: Include catalogs (default True).
222
+ catalog_ids: Filter to specific catalog IDs (optional).
223
+ ctx: MCP context for progress reporting (optional).
224
+
225
+ Returns:
226
+ dict with export_data or file_path, summary, metadata.
227
+ """
228
+ # Build export options
229
+ options: dict[str, Any] | None = None
230
+
231
+ if not include_all:
232
+ # Minimal export: only core metadata (granular options ignored)
233
+ options = {
234
+ "serviceSelected": True,
235
+ "registrationsSelected": True,
236
+ "costsSelected": False,
237
+ "requirementsSelected": False,
238
+ "resultsSelected": False,
239
+ "activityConditionsSelected": False,
240
+ "registrationLawsSelected": False,
241
+ "serviceLocationsSelected": False,
242
+ "serviceTutorialsSelected": False,
243
+ "serviceTranslationsSelected": False,
244
+ "guideFormSelected": False,
245
+ "applicantFormSelected": False,
246
+ "sendFileFormSelected": False,
247
+ "paymentFormSelected": False,
248
+ "catalogsSelected": False,
249
+ "rolesSelected": False,
250
+ "determinantsSelected": False,
251
+ "printDocumentsSelected": False,
252
+ "botsSelected": False,
253
+ "copyService": False,
254
+ }
255
+ # Force granular options to match include_all=False behavior for summary
256
+ include_forms = False
257
+ include_translations = False
258
+ include_catalogs = False
259
+ else:
260
+ # Check if any granular options differ from defaults
261
+ has_granular_options = (
262
+ not include_forms
263
+ or not include_translations
264
+ or not include_catalogs
265
+ or catalog_ids is not None
266
+ )
267
+
268
+ if has_granular_options:
269
+ # Build selective options with granular control
270
+ options = {
271
+ "serviceSelected": True,
272
+ "registrationsSelected": True,
273
+ "costsSelected": True,
274
+ "requirementsSelected": True,
275
+ "resultsSelected": True,
276
+ "activityConditionsSelected": True,
277
+ "registrationLawsSelected": True,
278
+ "serviceLocationsSelected": True,
279
+ "serviceTutorialsSelected": True,
280
+ "serviceTranslationsSelected": include_translations,
281
+ "guideFormSelected": include_forms,
282
+ "applicantFormSelected": include_forms,
283
+ "sendFileFormSelected": include_forms,
284
+ "paymentFormSelected": include_forms,
285
+ "catalogsSelected": include_catalogs,
286
+ "rolesSelected": True,
287
+ "determinantsSelected": True,
288
+ "printDocumentsSelected": True,
289
+ "botsSelected": True,
290
+ "copyService": False,
291
+ }
292
+ # Add catalog filter if specified
293
+ if include_catalogs and catalog_ids is not None:
294
+ options["catalogsToCopy"] = catalog_ids
295
+
296
+ # Report progress: starting export
297
+ if ctx:
298
+ await ctx.report_progress(
299
+ progress=0.1,
300
+ total=1.0,
301
+ message=f"Starting export for service {service_id}...",
302
+ )
303
+
304
+ try:
305
+ async with BPAClient() as client:
306
+ # Report progress: connected, requesting export
307
+ if ctx:
308
+ await ctx.report_progress(
309
+ progress=0.2,
310
+ total=1.0,
311
+ message="Connected to BPA. Requesting service export...",
312
+ )
313
+
314
+ try:
315
+ export_data, size_bytes = await client.download_service(
316
+ str(service_id),
317
+ options=options,
318
+ )
319
+ except BPANotFoundError:
320
+ raise ToolError(
321
+ f"[Service not found]: Service ID '{service_id}' does not exist. "
322
+ "[Verify the service ID using service_list]"
323
+ )
324
+
325
+ # Report progress: export received
326
+ if ctx:
327
+ size_mb = size_bytes / (1024 * 1024)
328
+ await ctx.report_progress(
329
+ progress=0.8,
330
+ total=1.0,
331
+ message=f"Export received ({size_mb:.2f} MB). Processing...",
332
+ )
333
+ except ToolError:
334
+ raise
335
+ except BPAClientError as e:
336
+ raise translate_error(e, resource_type="service", resource_id=service_id)
337
+
338
+ # Generate summary with selection options
339
+ summary = _generate_export_summary(
340
+ export_data,
341
+ size_bytes,
342
+ include_forms=include_forms,
343
+ include_translations=include_translations,
344
+ include_catalogs=include_catalogs,
345
+ )
346
+
347
+ # Build metadata
348
+ metadata = {
349
+ "exported_at": datetime.now(UTC).isoformat(),
350
+ "service_id": str(service_id),
351
+ "include_all": include_all,
352
+ }
353
+
354
+ # Handle large exports
355
+ if size_bytes >= LARGE_EXPORT_THRESHOLD_BYTES:
356
+ file_path = _save_export_to_file(export_data, str(service_id))
357
+
358
+ # Report progress: complete
359
+ if ctx:
360
+ await ctx.report_progress(
361
+ progress=1.0,
362
+ total=1.0,
363
+ message=f"Export complete. Saved to {file_path}",
364
+ )
365
+
366
+ return {
367
+ "file_path": file_path,
368
+ "summary": summary,
369
+ "metadata": metadata,
370
+ "message": (
371
+ f"Export saved to file ({summary['size_mb']} MB). "
372
+ "Use the file_path to access the full data. "
373
+ "Note: Temporary files should be cleaned up after use."
374
+ ),
375
+ }
376
+
377
+ # Report progress: complete
378
+ if ctx:
379
+ await ctx.report_progress(
380
+ progress=1.0,
381
+ total=1.0,
382
+ message="Export complete.",
383
+ )
384
+
385
+ # Return inline for smaller exports
386
+ return {
387
+ "export_data": export_data,
388
+ "summary": summary,
389
+ "metadata": metadata,
390
+ }
391
+
392
+
393
+ def _save_yaml_to_file(
394
+ content: str,
395
+ service_id: str,
396
+ extension: str = "yaml",
397
+ ) -> str:
398
+ """Save YAML/JSON content to a temporary file.
399
+
400
+ Args:
401
+ content: The YAML or JSON content to save.
402
+ service_id: The service ID for the filename.
403
+ extension: File extension to use (default "yaml").
404
+
405
+ Returns:
406
+ str: Path to the saved file.
407
+ """
408
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
409
+ filename = f"bpa_service_{service_id}_{timestamp}.{extension}"
410
+
411
+ # Use system temp directory
412
+ temp_dir = Path(tempfile.gettempdir())
413
+ file_path = temp_dir / filename
414
+
415
+ with open(file_path, "w", encoding="utf-8") as f:
416
+ f.write(content)
417
+
418
+ return str(file_path)
419
+
420
+
421
+ async def service_to_yaml(
422
+ service_id: str | int,
423
+ export_data: dict[str, Any] | None = None,
424
+ include_metadata: bool = True,
425
+ format: str = "yaml",
426
+ ctx: Context[ServerSession, None] | None = None,
427
+ ) -> dict[str, Any]:
428
+ """Transform BPA service export to clean YAML/JSON format.
429
+
430
+ Converts camelCase to snake_case, extracts Form.io essentials,
431
+ parses determinant conditions. Large outputs (>500KB) saved to file.
432
+
433
+ Args:
434
+ service_id: BPA service UUID.
435
+ export_data: Pre-fetched export (optional, auto-fetches if None).
436
+ include_metadata: Include IDs for re-import (default True).
437
+ format: "yaml" (default) or "json".
438
+ ctx: MCP context for progress (optional).
439
+
440
+ Returns:
441
+ dict with yaml_content/json_content or file_path, summary, metadata.
442
+ """
443
+ # Validate format parameter
444
+ valid_formats = ("yaml", "json")
445
+ if format.lower() not in valid_formats:
446
+ raise ToolError(
447
+ f"[Invalid format]: '{format}' is not valid. "
448
+ f"[Use one of: {', '.join(valid_formats)}]"
449
+ )
450
+ # Report progress: starting
451
+ if ctx:
452
+ await ctx.report_progress(
453
+ progress=0.1,
454
+ total=1.0,
455
+ message="Starting YAML transformation...",
456
+ )
457
+
458
+ # Get export data if not provided
459
+ if export_data is None:
460
+ if ctx:
461
+ await ctx.report_progress(
462
+ progress=0.2,
463
+ total=1.0,
464
+ message=f"Fetching export for service {service_id}...",
465
+ )
466
+
467
+ # Fetch raw export
468
+ result = await service_export_raw(
469
+ service_id=service_id,
470
+ include_all=True,
471
+ ctx=None, # Don't double-report progress
472
+ )
473
+
474
+ # Handle file-based exports (large files)
475
+ if "file_path" in result:
476
+ # Read the export from file
477
+ with open(result["file_path"], encoding="utf-8") as f:
478
+ export_data = json.load(f)
479
+ else:
480
+ export_data = result.get("export_data", {})
481
+
482
+ # Normalize format
483
+ output_format = format.lower()
484
+
485
+ if ctx:
486
+ await ctx.report_progress(
487
+ progress=0.5,
488
+ total=1.0,
489
+ message=f"Transforming to {output_format.upper()}...",
490
+ )
491
+
492
+ # Transform using the transformer
493
+ transformer = YAMLTransformer(include_metadata=include_metadata)
494
+
495
+ try:
496
+ # Get the optimized data structure
497
+ data_dict, transform_summary = transformer.transform_to_dict(export_data)
498
+
499
+ # Serialize to requested format
500
+ if output_format == "yaml":
501
+ import yaml as yaml_module
502
+
503
+ content = yaml_module.dump(
504
+ data_dict,
505
+ default_flow_style=False,
506
+ allow_unicode=True,
507
+ sort_keys=False,
508
+ width=120,
509
+ )
510
+ content_key = "yaml_content"
511
+ file_ext = "yaml"
512
+ else: # json
513
+ content = json.dumps(data_dict, indent=2, ensure_ascii=False)
514
+ content_key = "json_content"
515
+ file_ext = "json"
516
+
517
+ except TransformationError as e:
518
+ # Save raw JSON for debugging
519
+ raw_file_path = _save_export_to_file(export_data, str(service_id))
520
+ raise ToolError(
521
+ f"[Transformation failed]: {e}. [Raw JSON preserved at {raw_file_path}]"
522
+ )
523
+
524
+ # Calculate size
525
+ content_size = len(content.encode("utf-8"))
526
+
527
+ # Build result metadata
528
+ result_metadata = {
529
+ "transformed_at": datetime.now(UTC).isoformat(),
530
+ "service_id": str(service_id),
531
+ "schema_version": "1.0",
532
+ "include_metadata": include_metadata,
533
+ "format": output_format,
534
+ }
535
+
536
+ # Merge transformer summary with size info
537
+ summary = {
538
+ **transform_summary,
539
+ "size_bytes": content_size,
540
+ "size_kb": round(content_size / 1024, 2),
541
+ "format": output_format,
542
+ }
543
+
544
+ # Handle large output
545
+ if content_size >= LARGE_YAML_THRESHOLD_BYTES:
546
+ file_path = _save_yaml_to_file(content, str(service_id), extension=file_ext)
547
+
548
+ if ctx:
549
+ await ctx.report_progress(
550
+ progress=1.0,
551
+ total=1.0,
552
+ message=f"{output_format.upper()} saved to {file_path}",
553
+ )
554
+
555
+ return {
556
+ "file_path": file_path,
557
+ "summary": summary,
558
+ "metadata": result_metadata,
559
+ "message": (
560
+ f"{output_format.upper()} saved to file ({summary['size_kb']} KB). "
561
+ "Use the file_path to access the full content. "
562
+ "Note: Temporary files should be cleaned up after use."
563
+ ),
564
+ }
565
+
566
+ # Report completion
567
+ if ctx:
568
+ await ctx.report_progress(
569
+ progress=1.0,
570
+ total=1.0,
571
+ message=f"{output_format.upper()} transformation complete.",
572
+ )
573
+
574
+ return {
575
+ content_key: content,
576
+ "summary": summary,
577
+ "metadata": result_metadata,
578
+ }
579
+
580
+
581
+ async def service_copy(
582
+ source_service_id: str | int,
583
+ new_name: str | None = None,
584
+ include_forms: bool = True,
585
+ include_roles: bool = True,
586
+ include_determinants: bool = True,
587
+ include_bots: bool = True,
588
+ include_catalogs: bool = True,
589
+ include_translations: bool = False,
590
+ include_tutorials: bool = False,
591
+ catalog_ids: list[str] | None = None,
592
+ ctx: Context[ServerSession, None] | None = None,
593
+ ) -> dict[str, Any]:
594
+ """Copy a service with a new name. Audited write operation.
595
+
596
+ Args:
597
+ source_service_id: Service UUID to copy.
598
+ new_name: New service name (default: "{original} - copy").
599
+ include_forms: Copy forms (default True).
600
+ include_roles: Copy roles (default True).
601
+ include_determinants: Copy determinants (default True).
602
+ include_bots: Copy bots (default True).
603
+ include_catalogs: Copy catalogs (default True).
604
+ include_translations: Copy translations (default False).
605
+ include_tutorials: Copy tutorials (default False).
606
+ catalog_ids: Specific catalogs to copy (optional).
607
+ ctx: MCP context for progress (optional).
608
+
609
+ Returns:
610
+ dict with new_service_id, new_service_name, source_service_id,
611
+ source_service_name, components_copied, components_excluded, warnings, audit_id.
612
+ """
613
+ # Pre-flight validation
614
+ if not source_service_id or str(source_service_id).strip() == "":
615
+ raise ToolError(
616
+ "[Validation error]: source_service_id is required. "
617
+ "[Use service_list to find available services]"
618
+ )
619
+
620
+ if new_name is not None and new_name.strip() == "":
621
+ raise ToolError(
622
+ "[Validation error]: new_name cannot be empty whitespace. "
623
+ "[Provide a valid name or omit to use default naming]"
624
+ )
625
+
626
+ # Get authenticated user for audit
627
+ try:
628
+ user_email = get_current_user_email()
629
+ except NotAuthenticatedError as e:
630
+ raise ToolError(str(e))
631
+
632
+ # Fetch source service to verify it exists and get original name
633
+ if ctx:
634
+ await ctx.report_progress(
635
+ progress=0.1,
636
+ total=1.0,
637
+ message=f"Verifying source service {source_service_id}...",
638
+ )
639
+
640
+ try:
641
+ async with BPAClient() as client:
642
+ try:
643
+ source_service = await client.get(
644
+ f"/service/{source_service_id}",
645
+ resource_type="service",
646
+ )
647
+ except BPANotFoundError:
648
+ raise ToolError(
649
+ f"[Service not found]: Service ID '{source_service_id}' "
650
+ "does not exist. [Use service_list to see available services]"
651
+ )
652
+
653
+ source_name = source_service.get("name", "Unknown")
654
+
655
+ # Determine new service name
656
+ target_name = new_name.strip() if new_name else f"{source_name} - copy"
657
+
658
+ # Build copy payload
659
+ copy_options = _build_copy_payload(
660
+ new_service_name=target_name,
661
+ include_forms=include_forms,
662
+ include_roles=include_roles,
663
+ include_determinants=include_determinants,
664
+ include_bots=include_bots,
665
+ include_catalogs=include_catalogs,
666
+ include_translations=include_translations,
667
+ include_tutorials=include_tutorials,
668
+ catalog_ids=catalog_ids,
669
+ )
670
+
671
+ # Track what will be copied/excluded
672
+ components_copied = ["registrations"] # Always copied
673
+ components_excluded = []
674
+
675
+ if include_forms:
676
+ components_copied.append("forms")
677
+ else:
678
+ components_excluded.append("forms")
679
+
680
+ if include_roles:
681
+ components_copied.append("roles")
682
+ else:
683
+ components_excluded.append("roles")
684
+
685
+ if include_determinants:
686
+ components_copied.append("determinants")
687
+ else:
688
+ components_excluded.append("determinants")
689
+
690
+ if include_bots:
691
+ components_copied.append("bots")
692
+ else:
693
+ components_excluded.append("bots")
694
+
695
+ if include_catalogs:
696
+ components_copied.append("catalogs")
697
+ else:
698
+ components_excluded.append("catalogs")
699
+
700
+ if include_translations:
701
+ components_copied.append("translations")
702
+ else:
703
+ components_excluded.append("translations")
704
+
705
+ if include_tutorials:
706
+ components_copied.append("tutorials")
707
+ else:
708
+ components_excluded.append("tutorials")
709
+
710
+ # Create audit record BEFORE API call (audit-before-write pattern)
711
+ audit_logger = AuditLogger()
712
+ audit_params = {
713
+ "source_service_id": str(source_service_id),
714
+ "source_service_name": source_name,
715
+ "new_service_name": target_name,
716
+ "include_forms": include_forms,
717
+ "include_roles": include_roles,
718
+ "include_determinants": include_determinants,
719
+ "include_bots": include_bots,
720
+ "include_catalogs": include_catalogs,
721
+ "include_translations": include_translations,
722
+ "include_tutorials": include_tutorials,
723
+ "catalog_ids": catalog_ids,
724
+ }
725
+ audit_id = await audit_logger.record_pending(
726
+ user_email=user_email,
727
+ operation_type="copy",
728
+ object_type="service",
729
+ object_id=str(source_service_id),
730
+ params=audit_params,
731
+ )
732
+
733
+ if ctx:
734
+ await ctx.report_progress(
735
+ progress=0.3,
736
+ total=1.0,
737
+ message=f"Copying service to '{target_name}'...",
738
+ )
739
+
740
+ try:
741
+ # Execute the copy operation
742
+ copy_result, _size = await client.download_service(
743
+ str(source_service_id),
744
+ options=copy_options,
745
+ )
746
+
747
+ # Extract new service ID from response
748
+ new_service_id = copy_result.get("id")
749
+ new_service_name = copy_result.get("name", target_name)
750
+
751
+ # Mark audit as success
752
+ await audit_logger.mark_success(
753
+ audit_id,
754
+ result={
755
+ "new_service_id": new_service_id,
756
+ "new_service_name": new_service_name,
757
+ "source_service_id": str(source_service_id),
758
+ },
759
+ )
760
+
761
+ except BPAClientError as e:
762
+ # Mark audit as failed
763
+ await audit_logger.mark_failed(audit_id, str(e))
764
+ raise
765
+
766
+ except ToolError:
767
+ raise
768
+ except BPAClientError as e:
769
+ raise translate_error(e, resource_type="service", resource_id=source_service_id)
770
+
771
+ if ctx:
772
+ await ctx.report_progress(
773
+ progress=1.0,
774
+ total=1.0,
775
+ message="Service copy complete.",
776
+ )
777
+
778
+ # Generate warnings for potential broken references
779
+ warnings = _generate_copy_warnings(
780
+ include_forms=include_forms,
781
+ include_catalogs=include_catalogs,
782
+ include_determinants=include_determinants,
783
+ )
784
+
785
+ return {
786
+ "new_service_id": new_service_id,
787
+ "new_service_name": new_service_name,
788
+ "source_service_id": str(source_service_id),
789
+ "source_service_name": source_name,
790
+ "components_copied": components_copied,
791
+ "components_excluded": components_excluded,
792
+ "warnings": warnings,
793
+ "audit_id": audit_id,
794
+ }
795
+
796
+
797
+ def _build_copy_payload(
798
+ new_service_name: str,
799
+ include_forms: bool,
800
+ include_roles: bool,
801
+ include_determinants: bool,
802
+ include_bots: bool,
803
+ include_catalogs: bool,
804
+ include_translations: bool,
805
+ include_tutorials: bool,
806
+ catalog_ids: list[str] | None,
807
+ ) -> dict[str, Any]:
808
+ """Build the payload for the copy service API call.
809
+
810
+ Args:
811
+ new_service_name: Name for the new service.
812
+ include_forms: Whether to copy forms.
813
+ include_roles: Whether to copy roles.
814
+ include_determinants: Whether to copy determinants.
815
+ include_bots: Whether to copy bots.
816
+ include_catalogs: Whether to copy catalogs.
817
+ include_translations: Whether to copy translations.
818
+ include_tutorials: Whether to copy tutorials.
819
+ catalog_ids: Specific catalog IDs to copy, or None for all.
820
+
821
+ Returns:
822
+ dict: The payload for the download_service API call.
823
+ """
824
+ payload: dict[str, Any] = {
825
+ "serviceSelected": True,
826
+ "copyService": True,
827
+ "newServiceName": new_service_name,
828
+ # Core components always copied
829
+ "costsSelected": True,
830
+ "requirementsSelected": True,
831
+ "resultsSelected": True,
832
+ "activityConditionsSelected": True,
833
+ "registrationLawsSelected": True,
834
+ "serviceLocationsSelected": True,
835
+ "registrationsSelected": True,
836
+ "printDocumentsSelected": True,
837
+ # Configurable components
838
+ "guideFormSelected": include_forms,
839
+ "applicantFormSelected": include_forms,
840
+ "sendFileFormSelected": include_forms,
841
+ "paymentFormSelected": include_forms,
842
+ "rolesSelected": include_roles,
843
+ "determinantsSelected": include_determinants,
844
+ "botsSelected": include_bots,
845
+ "catalogsSelected": include_catalogs,
846
+ "serviceTranslationsSelected": include_translations,
847
+ "serviceTutorialsSelected": include_tutorials,
848
+ }
849
+
850
+ # Add specific catalog IDs if provided
851
+ if include_catalogs and catalog_ids is not None:
852
+ payload["catalogsToCopy"] = catalog_ids
853
+
854
+ return payload
855
+
856
+
857
+ def _generate_copy_warnings(
858
+ include_forms: bool,
859
+ include_catalogs: bool,
860
+ include_determinants: bool,
861
+ ) -> list[str]:
862
+ """Generate warnings about potential broken references in the copy.
863
+
864
+ Args:
865
+ include_forms: Whether forms were copied.
866
+ include_catalogs: Whether catalogs were copied.
867
+ include_determinants: Whether determinants were copied.
868
+
869
+ Returns:
870
+ list: Warning messages about potential issues.
871
+ """
872
+ warnings: list[str] = []
873
+
874
+ # Warn about catalog references in forms
875
+ if include_forms and not include_catalogs:
876
+ warnings.append(
877
+ "Forms were copied but catalogs were excluded. "
878
+ "Catalog references in forms may be broken."
879
+ )
880
+
881
+ # Warn about determinant references
882
+ if include_forms and not include_determinants:
883
+ warnings.append(
884
+ "Forms were copied but determinants were excluded. "
885
+ "Field visibility rules may not work correctly."
886
+ )
887
+
888
+ return warnings
889
+
890
+
891
+ def register_export_tools(mcp: Any) -> None:
892
+ """Register export tools with the MCP server.
893
+
894
+ Args:
895
+ mcp: The FastMCP server instance.
896
+ """
897
+ mcp.tool()(service_export_raw)
898
+ mcp.tool()(service_to_yaml)
899
+ mcp.tool()(service_copy)