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,575 @@
1
+ """MCP tools for BPA classification/catalog operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, and updating
4
+ BPA classifications (catalog data sources used for dropdown fields in forms).
5
+
6
+ Write operations follow the audit-before-write pattern:
7
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
8
+ 2. Create PENDING audit record
9
+ 3. Execute BPA API call
10
+ 4. Update audit record to SUCCESS or FAILED
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from mcp.server.fastmcp.exceptions import ToolError
18
+
19
+ from mcp_eregistrations_bpa.audit.context import (
20
+ NotAuthenticatedError,
21
+ get_current_user_email,
22
+ )
23
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
24
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
25
+ from mcp_eregistrations_bpa.bpa_client.errors import (
26
+ BPAClientError,
27
+ BPANotFoundError,
28
+ translate_error,
29
+ )
30
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
31
+
32
+ __all__ = [
33
+ "classification_list",
34
+ "classification_get",
35
+ "classification_create",
36
+ "classification_update",
37
+ "classification_export_csv",
38
+ "register_classification_tools",
39
+ ]
40
+
41
+
42
+ def _transform_classification_summary(classification: dict[str, Any]) -> dict[str, Any]:
43
+ """Transform classification to summary format with snake_case keys."""
44
+ return {
45
+ "id": classification.get("id"),
46
+ "name": classification.get("name"),
47
+ "type": classification.get("type") or classification.get("classificationType"),
48
+ "entry_count": (
49
+ classification.get("entryCount") or classification.get("size", 0)
50
+ ),
51
+ }
52
+
53
+
54
+ def _transform_classification_detail(classification: dict[str, Any]) -> dict[str, Any]:
55
+ """Transform classification to detailed format with entries."""
56
+ # Handle entries - may be in 'content' (pageable) or 'entries' field
57
+ raw_entries = classification.get("content") or classification.get("entries") or []
58
+ entries = []
59
+ for entry in raw_entries:
60
+ entries.append(
61
+ {
62
+ "value": entry.get("value") or entry.get("key"),
63
+ "label": entry.get("label") or entry.get("name") or entry.get("value"),
64
+ }
65
+ )
66
+
67
+ return {
68
+ "id": classification.get("id"),
69
+ "name": classification.get("name"),
70
+ "type": classification.get("type") or classification.get("classificationType"),
71
+ "entries": entries,
72
+ "entry_count": len(entries),
73
+ "created_at": classification.get("createdAt"),
74
+ "updated_at": classification.get("updatedAt"),
75
+ }
76
+
77
+
78
+ async def classification_list(
79
+ limit: int = 50,
80
+ offset: int = 0,
81
+ name_filter: str | None = None,
82
+ ) -> dict[str, Any]:
83
+ """List all classifications (catalog data sources).
84
+
85
+ Args:
86
+ limit: Maximum to return (default: 50).
87
+ offset: Skip count (default: 0).
88
+ name_filter: Filter by name (contains, case-insensitive).
89
+
90
+ Returns:
91
+ dict with classifications, total, has_more.
92
+ """
93
+ # Normalize pagination parameters
94
+ if limit <= 0:
95
+ limit = 50
96
+ if offset < 0:
97
+ offset = 0
98
+
99
+ try:
100
+ async with BPAClient() as client:
101
+ classifications_data = await client.get_list(
102
+ "/classification",
103
+ resource_type="classification",
104
+ )
105
+ except BPAClientError as e:
106
+ raise translate_error(e, resource_type="classification")
107
+
108
+ # Transform to consistent output format with snake_case keys
109
+ all_classifications = [
110
+ _transform_classification_summary(c) for c in classifications_data
111
+ ]
112
+
113
+ # Apply name filter if provided
114
+ if name_filter:
115
+ name_filter_lower = name_filter.lower()
116
+ all_classifications = [
117
+ c
118
+ for c in all_classifications
119
+ if name_filter_lower in (c.get("name") or "").lower()
120
+ ]
121
+
122
+ # Sort by name for consistent pagination ordering
123
+ all_classifications.sort(key=lambda c: c.get("name") or "")
124
+
125
+ # Calculate total before pagination
126
+ total = len(all_classifications)
127
+
128
+ # Apply pagination
129
+ paginated = all_classifications[offset : offset + limit]
130
+
131
+ # Calculate has_more
132
+ has_more = (offset + limit) < total
133
+
134
+ return {
135
+ "classifications": paginated,
136
+ "total": total,
137
+ "has_more": has_more,
138
+ }
139
+
140
+
141
+ @large_response_handler(
142
+ navigation={
143
+ "list_entries": "jq '.entries'",
144
+ "find_by_value": "jq '.entries[] | select(.value == \"CODE\")'",
145
+ "find_by_label": "jq '.entries[] | select(.label | contains(\"search\"))'",
146
+ "entry_count": "jq '.entry_count'",
147
+ }
148
+ )
149
+ async def classification_get(classification_id: str | int) -> dict[str, Any]:
150
+ """Get classification details by ID including entries.
151
+
152
+ Large responses (>100KB) are saved to file with navigation hints.
153
+
154
+ Args:
155
+ classification_id: The classification ID.
156
+
157
+ Returns:
158
+ dict with id, name, type, entries, entry_count, created_at, updated_at.
159
+ """
160
+ try:
161
+ async with BPAClient() as client:
162
+ try:
163
+ # Use pageable endpoint to get entries
164
+ classification_data = await client.get(
165
+ "/classification/{id}/pageable",
166
+ path_params={"id": classification_id},
167
+ resource_type="classification",
168
+ resource_id=classification_id,
169
+ )
170
+ except BPANotFoundError:
171
+ raise ToolError(
172
+ f"Classification '{classification_id}' not found. "
173
+ "Use 'classification_list' to see available classifications."
174
+ )
175
+ except ToolError:
176
+ raise
177
+ except BPAClientError as e:
178
+ raise translate_error(
179
+ e, resource_type="classification", resource_id=classification_id
180
+ )
181
+
182
+ return _transform_classification_detail(classification_data)
183
+
184
+
185
+ def _validate_classification_create_params(
186
+ classification_type_id: str,
187
+ name: str,
188
+ entries: list[dict[str, str]],
189
+ ) -> dict[str, Any]:
190
+ """Validate classification_create parameters (pre-flight).
191
+
192
+ Args:
193
+ classification_type_id: The type of classification to create.
194
+ name: The classification name.
195
+ entries: List of entries with value and label.
196
+
197
+ Returns:
198
+ dict: Validated parameters ready for API call.
199
+
200
+ Raises:
201
+ ToolError: If validation fails.
202
+ """
203
+ errors = []
204
+
205
+ if not classification_type_id or not classification_type_id.strip():
206
+ errors.append("'classification_type_id' is required")
207
+
208
+ if not name or not name.strip():
209
+ errors.append("'name' is required and cannot be empty")
210
+
211
+ if name and len(name.strip()) > 255:
212
+ errors.append("'name' must be 255 characters or less")
213
+
214
+ if not entries:
215
+ errors.append("'entries' must contain at least one entry")
216
+
217
+ # Validate entry structure
218
+ for i, entry in enumerate(entries or []):
219
+ if not isinstance(entry, dict):
220
+ errors.append(f"Entry {i} must be a dict with 'value' and 'label'")
221
+ continue
222
+ if not entry.get("value"):
223
+ errors.append(f"Entry {i} missing required 'value' field")
224
+ if not entry.get("label"):
225
+ errors.append(f"Entry {i} missing required 'label' field")
226
+
227
+ if errors:
228
+ error_msg = "; ".join(errors)
229
+ raise ToolError(
230
+ f"Cannot create classification: {error_msg}. Check required fields."
231
+ )
232
+
233
+ # Build API payload
234
+ formatted_entries = [{"value": e["value"], "label": e["label"]} for e in entries]
235
+
236
+ return {
237
+ "name": name.strip(),
238
+ "entries": formatted_entries,
239
+ }
240
+
241
+
242
+ async def classification_create(
243
+ classification_type_id: str,
244
+ name: str,
245
+ entries: list[dict[str, str]],
246
+ ) -> dict[str, Any]:
247
+ """Create a classification catalog. Audited write operation.
248
+
249
+ Args:
250
+ classification_type_id: The type ID for the classification.
251
+ name: The classification name.
252
+ entries: List of entries, each with 'value' and 'label' keys.
253
+
254
+ Returns:
255
+ dict with id, name, type, entries, entry_count, audit_id.
256
+ """
257
+ # Pre-flight validation (no audit record for validation failures)
258
+ validated_params = _validate_classification_create_params(
259
+ classification_type_id, name, entries
260
+ )
261
+
262
+ # Get authenticated user for audit
263
+ try:
264
+ user_email = get_current_user_email()
265
+ except NotAuthenticatedError as e:
266
+ raise ToolError(str(e))
267
+
268
+ try:
269
+ async with BPAClient() as client:
270
+ # Create audit record BEFORE API call
271
+ audit_logger = AuditLogger()
272
+ audit_id = await audit_logger.record_pending(
273
+ user_email=user_email,
274
+ operation_type="create",
275
+ object_type="classification",
276
+ params={
277
+ "classification_type_id": classification_type_id,
278
+ **validated_params,
279
+ },
280
+ )
281
+
282
+ try:
283
+ classification_data = await client.post(
284
+ "/classification/{classification_type_id}",
285
+ path_params={"classification_type_id": classification_type_id},
286
+ json=validated_params,
287
+ resource_type="classification",
288
+ )
289
+
290
+ # Save rollback state (for create, save ID to enable deletion)
291
+ created_id = classification_data.get("id")
292
+ await audit_logger.save_rollback_state(
293
+ audit_id=audit_id,
294
+ object_type="classification",
295
+ object_id=str(created_id),
296
+ previous_state={
297
+ "id": created_id,
298
+ "name": classification_data.get("name"),
299
+ "type": classification_data.get("type"),
300
+ "_operation": "create", # Marker for rollback to DELETE
301
+ },
302
+ )
303
+
304
+ # Mark audit as success
305
+ await audit_logger.mark_success(
306
+ audit_id,
307
+ result={
308
+ "classification_id": created_id,
309
+ "name": classification_data.get("name"),
310
+ },
311
+ )
312
+
313
+ result = _transform_classification_detail(classification_data)
314
+ result["audit_id"] = audit_id
315
+ return result
316
+
317
+ except BPAClientError as e:
318
+ await audit_logger.mark_failed(audit_id, str(e))
319
+ raise translate_error(e, resource_type="classification")
320
+
321
+ except ToolError:
322
+ raise
323
+ except BPAClientError as e:
324
+ raise translate_error(e, resource_type="classification")
325
+
326
+
327
+ def _validate_classification_update_params(
328
+ classification_id: str | int,
329
+ name: str | None,
330
+ entries: list[dict[str, str]] | None,
331
+ ) -> dict[str, Any]:
332
+ """Validate classification_update parameters (pre-flight).
333
+
334
+ Args:
335
+ classification_id: The classification ID to update.
336
+ name: New name (optional).
337
+ entries: New entries list (optional).
338
+
339
+ Returns:
340
+ dict: Validated parameters ready for API call.
341
+
342
+ Raises:
343
+ ToolError: If validation fails.
344
+ """
345
+ errors = []
346
+
347
+ if not classification_id:
348
+ errors.append("'classification_id' is required")
349
+
350
+ if name is not None and not name.strip():
351
+ errors.append("'name' cannot be empty when provided")
352
+
353
+ if name and len(name.strip()) > 255:
354
+ errors.append("'name' must be 255 characters or less")
355
+
356
+ # Validate entry structure if provided
357
+ if entries is not None:
358
+ if not entries:
359
+ errors.append("'entries' cannot be empty when provided")
360
+ for i, entry in enumerate(entries):
361
+ if not isinstance(entry, dict):
362
+ errors.append(f"Entry {i} must be a dict with 'value' and 'label'")
363
+ continue
364
+ if not entry.get("value"):
365
+ errors.append(f"Entry {i} missing required 'value' field")
366
+ if not entry.get("label"):
367
+ errors.append(f"Entry {i} missing required 'label' field")
368
+
369
+ # At least one field must be provided
370
+ if name is None and entries is None:
371
+ errors.append("At least one of 'name' or 'entries' must be provided")
372
+
373
+ if errors:
374
+ error_msg = "; ".join(errors)
375
+ raise ToolError(
376
+ f"Cannot update classification: {error_msg}. Check required fields."
377
+ )
378
+
379
+ # Build API payload with only provided fields
380
+ params: dict[str, Any] = {}
381
+ if name is not None:
382
+ params["name"] = name.strip()
383
+ if entries is not None:
384
+ params["entries"] = [
385
+ {"value": e["value"], "label": e["label"]} for e in entries
386
+ ]
387
+
388
+ return params
389
+
390
+
391
+ async def classification_update(
392
+ classification_id: str | int,
393
+ name: str | None = None,
394
+ entries: list[dict[str, str]] | None = None,
395
+ ) -> dict[str, Any]:
396
+ """Update a classification catalog. Audited write operation.
397
+
398
+ Args:
399
+ classification_id: The classification ID to update.
400
+ name: New name (optional).
401
+ entries: New entries list (optional).
402
+
403
+ Returns:
404
+ dict with id, name, type, entries, entry_count, audit_id.
405
+ """
406
+ # Pre-flight validation
407
+ validated_params = _validate_classification_update_params(
408
+ classification_id, name, entries
409
+ )
410
+
411
+ # Get authenticated user for audit
412
+ try:
413
+ user_email = get_current_user_email()
414
+ except NotAuthenticatedError as e:
415
+ raise ToolError(str(e))
416
+
417
+ try:
418
+ async with BPAClient() as client:
419
+ # Fetch current state for rollback
420
+ try:
421
+ current_data = await client.get(
422
+ "/classification/{id}/pageable",
423
+ path_params={"id": classification_id},
424
+ resource_type="classification",
425
+ resource_id=classification_id,
426
+ )
427
+ except BPANotFoundError:
428
+ raise ToolError(
429
+ f"Classification '{classification_id}' not found. "
430
+ "Use 'classification_list' to see available classifications."
431
+ )
432
+
433
+ # Create audit record BEFORE API call
434
+ audit_logger = AuditLogger()
435
+ audit_id = await audit_logger.record_pending(
436
+ user_email=user_email,
437
+ operation_type="update",
438
+ object_type="classification",
439
+ object_id=str(classification_id),
440
+ params=validated_params,
441
+ )
442
+
443
+ try:
444
+ # Add id to payload for PUT request
445
+ update_payload = {"id": classification_id, **validated_params}
446
+
447
+ classification_data = await client.put(
448
+ "/classification/{id}",
449
+ path_params={"id": classification_id},
450
+ json=update_payload,
451
+ resource_type="classification",
452
+ resource_id=classification_id,
453
+ )
454
+
455
+ # Save rollback state (previous state for restore)
456
+ await audit_logger.save_rollback_state(
457
+ audit_id=audit_id,
458
+ object_type="classification",
459
+ object_id=str(classification_id),
460
+ previous_state={
461
+ "id": current_data.get("id"),
462
+ "name": current_data.get("name"),
463
+ "type": current_data.get("type"),
464
+ "entries": current_data.get("content")
465
+ or current_data.get("entries"),
466
+ "_operation": "update",
467
+ },
468
+ )
469
+
470
+ # Mark audit as success
471
+ await audit_logger.mark_success(
472
+ audit_id,
473
+ result={
474
+ "classification_id": classification_id,
475
+ "name": classification_data.get("name"),
476
+ },
477
+ )
478
+
479
+ result = _transform_classification_detail(classification_data)
480
+ result["audit_id"] = audit_id
481
+ return result
482
+
483
+ except BPAClientError as e:
484
+ await audit_logger.mark_failed(audit_id, str(e))
485
+ raise translate_error(
486
+ e, resource_type="classification", resource_id=classification_id
487
+ )
488
+
489
+ except ToolError:
490
+ raise
491
+ except BPAClientError as e:
492
+ raise translate_error(
493
+ e, resource_type="classification", resource_id=classification_id
494
+ )
495
+
496
+
497
+ async def classification_export_csv(classification_id: str | int) -> dict[str, Any]:
498
+ """Export classification entries as CSV content.
499
+
500
+ Args:
501
+ classification_id: The classification ID to export.
502
+
503
+ Returns:
504
+ dict with classification_id, name, csv_content, entry_count.
505
+ """
506
+ try:
507
+ async with BPAClient() as client:
508
+ try:
509
+ # First get classification details for metadata
510
+ classification_data = await client.get(
511
+ "/classification/{id}/pageable",
512
+ path_params={"id": classification_id},
513
+ resource_type="classification",
514
+ resource_id=classification_id,
515
+ )
516
+ except BPANotFoundError:
517
+ raise ToolError(
518
+ f"Classification '{classification_id}' not found. "
519
+ "Use 'classification_list' to see available classifications."
520
+ )
521
+
522
+ # Get CSV export
523
+ try:
524
+ csv_response = await client.get(
525
+ "/classification/{id}/exportCsv",
526
+ path_params={"id": classification_id},
527
+ resource_type="classification",
528
+ resource_id=classification_id,
529
+ )
530
+ except BPAClientError as e:
531
+ raise translate_error(
532
+ e, resource_type="classification", resource_id=classification_id
533
+ )
534
+
535
+ # The response may be raw CSV text or a dict with content
536
+ if isinstance(csv_response, str):
537
+ csv_content = csv_response
538
+ elif isinstance(csv_response, dict):
539
+ csv_content = csv_response.get("content", str(csv_response))
540
+ else:
541
+ csv_content = str(csv_response)
542
+
543
+ # Count entries from original data
544
+ raw_entries = (
545
+ classification_data.get("content")
546
+ or classification_data.get("entries")
547
+ or []
548
+ )
549
+
550
+ return {
551
+ "classification_id": classification_id,
552
+ "name": classification_data.get("name"),
553
+ "csv_content": csv_content,
554
+ "entry_count": len(raw_entries),
555
+ }
556
+
557
+ except ToolError:
558
+ raise
559
+ except BPAClientError as e:
560
+ raise translate_error(
561
+ e, resource_type="classification", resource_id=classification_id
562
+ )
563
+
564
+
565
+ def register_classification_tools(mcp: Any) -> None:
566
+ """Register classification tools with the MCP server.
567
+
568
+ Args:
569
+ mcp: The FastMCP server instance.
570
+ """
571
+ mcp.tool()(classification_list)
572
+ mcp.tool()(classification_get)
573
+ mcp.tool()(classification_create)
574
+ mcp.tool()(classification_update)
575
+ mcp.tool()(classification_export_csv)