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,680 @@
1
+ """MCP tools for BPA registration institution operations.
2
+
3
+ This module provides tools for listing, retrieving, creating, and deleting
4
+ registration institution assignments. Registration institutions link a
5
+ registration to an institution, which is required for publishing services.
6
+
7
+ Write operations follow the audit-before-write pattern:
8
+ 1. Validate parameters (pre-flight, no audit record if validation fails)
9
+ 2. Create PENDING audit record
10
+ 3. Execute BPA API call
11
+ 4. Update audit record to SUCCESS or FAILED
12
+
13
+ API Endpoints used:
14
+ - GET /registration/{registration_id}/registration_institution - List for registration
15
+ - POST /registration/{registration_id}/registration_institution - Create assignment
16
+ - GET /registration_institution/{registration_institution_id} - Get by ID
17
+ - DELETE /registration_institution/{registration_institution_id} - Delete assignment
18
+ - GET /registration_institution_by_institution/{institution_id} - List by institution
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from typing import Any
25
+
26
+ import httpx
27
+ from mcp.server.fastmcp.exceptions import ToolError
28
+
29
+ from mcp_eregistrations_bpa.audit.context import (
30
+ NotAuthenticatedError,
31
+ get_current_user_email,
32
+ get_token_manager,
33
+ )
34
+ from mcp_eregistrations_bpa.audit.logger import AuditLogger
35
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
36
+ from mcp_eregistrations_bpa.bpa_client.errors import (
37
+ BPAClientError,
38
+ BPANotFoundError,
39
+ translate_error,
40
+ )
41
+ from mcp_eregistrations_bpa.config import load_config
42
+ from mcp_eregistrations_bpa.tools.large_response import large_response_handler
43
+
44
+ __all__ = [
45
+ "registrationinstitution_list",
46
+ "registrationinstitution_get",
47
+ "registrationinstitution_create",
48
+ "registrationinstitution_delete",
49
+ "registrationinstitution_list_by_institution",
50
+ "institution_discover",
51
+ "institution_create",
52
+ "register_registration_institution_tools",
53
+ ]
54
+
55
+ # Default institutions parent group ID in Keycloak
56
+ # Can be overridden with KEYCLOAK_INSTITUTIONS_GROUP_ID env var
57
+ DEFAULT_INSTITUTIONS_PARENT_GROUP = "967d3d31-5114-4131-b7e1-f5c652227259"
58
+
59
+
60
+ def _transform_response(data: dict[str, Any]) -> dict[str, Any]:
61
+ """Transform registration institution API response from camelCase to snake_case.
62
+
63
+ Args:
64
+ data: Raw API response with camelCase keys.
65
+
66
+ Returns:
67
+ dict: Transformed response with snake_case keys.
68
+ """
69
+ return {
70
+ "id": data.get("id"),
71
+ "registration_id": data.get("registrationId"),
72
+ "institution_id": data.get("institutionId"),
73
+ }
74
+
75
+
76
+ @large_response_handler(
77
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
78
+ navigation={
79
+ "list_all": "jq '.assignments'",
80
+ "by_institution": "jq '.assignments[] | select(.institution_id==\"x\")'",
81
+ },
82
+ )
83
+ async def registrationinstitution_list(
84
+ registration_id: str | int,
85
+ ) -> dict[str, Any]:
86
+ """List institution assignments for a registration.
87
+
88
+ Large responses (>50KB) are saved to file with navigation hints.
89
+
90
+ Args:
91
+ registration_id: Registration ID to list assignments for.
92
+
93
+ Returns:
94
+ dict with assignments, registration_id, total.
95
+ """
96
+ if not registration_id:
97
+ raise ToolError(
98
+ "Cannot list registration institutions: 'registration_id' is required. "
99
+ "Use 'registration_list' to find valid registration IDs."
100
+ )
101
+
102
+ try:
103
+ async with BPAClient() as client:
104
+ try:
105
+ data = await client.get_list(
106
+ "/registration/{registration_id}/registration_institution",
107
+ path_params={"registration_id": registration_id},
108
+ resource_type="registration_institution",
109
+ )
110
+ except BPANotFoundError:
111
+ raise ToolError(
112
+ f"Registration '{registration_id}' not found. "
113
+ "Use 'registration_list' to see available registrations."
114
+ )
115
+ except ToolError:
116
+ raise
117
+ except BPAClientError as e:
118
+ raise translate_error(e, resource_type="registration_institution")
119
+
120
+ # Transform to consistent output format
121
+ assignments = [_transform_response(item) for item in data]
122
+
123
+ return {
124
+ "assignments": assignments,
125
+ "registration_id": registration_id,
126
+ "total": len(assignments),
127
+ }
128
+
129
+
130
+ async def registrationinstitution_get(
131
+ registration_institution_id: str | int,
132
+ ) -> dict[str, Any]:
133
+ """Get registration institution assignment by ID.
134
+
135
+ Args:
136
+ registration_institution_id: Assignment ID.
137
+
138
+ Returns:
139
+ dict with id, registration_id, institution_id.
140
+ """
141
+ if not registration_institution_id:
142
+ raise ToolError(
143
+ "Cannot get registration institution: "
144
+ "'registration_institution_id' is required."
145
+ )
146
+
147
+ try:
148
+ async with BPAClient() as client:
149
+ try:
150
+ data = await client.get(
151
+ "/registration_institution/{registration_institution_id}",
152
+ path_params={
153
+ "registration_institution_id": registration_institution_id
154
+ },
155
+ )
156
+ except BPANotFoundError:
157
+ raise ToolError(
158
+ f"Registration institution '{registration_institution_id}' "
159
+ "not found."
160
+ )
161
+ except ToolError:
162
+ raise
163
+ except BPAClientError as e:
164
+ raise translate_error(e, resource_type="registration_institution")
165
+
166
+ return _transform_response(data)
167
+
168
+
169
+ async def registrationinstitution_create(
170
+ registration_id: str | int,
171
+ institution_id: str,
172
+ ) -> dict[str, Any]:
173
+ """Assign institution to registration. Audited write operation.
174
+
175
+ Required for publishing services.
176
+
177
+ Args:
178
+ registration_id: Registration ID to assign institution to.
179
+ institution_id: Institution ID to assign.
180
+
181
+ Returns:
182
+ dict with id, registration_id, institution_id, audit_id.
183
+ """
184
+ # Pre-flight validation (no audit if these fail)
185
+ if not registration_id:
186
+ raise ToolError(
187
+ "Cannot create registration institution: 'registration_id' is required. "
188
+ "Use 'registration_list' to find valid registration IDs."
189
+ )
190
+ if not institution_id:
191
+ raise ToolError(
192
+ "Cannot create registration institution: 'institution_id' is required."
193
+ )
194
+
195
+ # Get user email for audit
196
+ try:
197
+ user_email = get_current_user_email()
198
+ except NotAuthenticatedError:
199
+ raise ToolError(
200
+ "Authentication required to create registration institution assignment. "
201
+ "Use 'auth_login' to authenticate first."
202
+ )
203
+
204
+ audit_logger = AuditLogger()
205
+
206
+ try:
207
+ async with BPAClient() as client:
208
+ # Verify registration exists (no audit if not found)
209
+ try:
210
+ await client.get(
211
+ "/registration/{registration_id}",
212
+ path_params={"registration_id": registration_id},
213
+ )
214
+ except BPANotFoundError:
215
+ raise ToolError(
216
+ f"Registration '{registration_id}' not found. "
217
+ "Use 'registration_list' to see available registrations."
218
+ )
219
+
220
+ # Create PENDING audit record
221
+ audit_id = await audit_logger.record_pending(
222
+ user_email=user_email,
223
+ operation_type="create",
224
+ object_type="registration_institution",
225
+ params={
226
+ "registration_id": registration_id,
227
+ "institution_id": institution_id,
228
+ },
229
+ )
230
+
231
+ # Execute API call - body is the raw institution_id string
232
+ try:
233
+ result = await client.post(
234
+ "/registration/{registration_id}/registration_institution",
235
+ path_params={"registration_id": registration_id},
236
+ content=institution_id,
237
+ )
238
+
239
+ # Mark audit success
240
+ await audit_logger.mark_success(
241
+ audit_id=audit_id,
242
+ result=result,
243
+ )
244
+
245
+ except Exception as e:
246
+ # Mark audit failed
247
+ await audit_logger.mark_failed(
248
+ audit_id=audit_id,
249
+ error_message=str(e),
250
+ )
251
+ raise
252
+
253
+ except ToolError:
254
+ raise
255
+ except BPAClientError as e:
256
+ raise translate_error(e, resource_type="registration_institution")
257
+
258
+ response = _transform_response(result)
259
+ response["audit_id"] = audit_id
260
+ return response
261
+
262
+
263
+ async def registrationinstitution_delete(
264
+ registration_institution_id: str | int,
265
+ ) -> dict[str, Any]:
266
+ """Delete registration institution assignment. Audited write operation.
267
+
268
+ Saves state before deletion; use rollback with audit_id to restore.
269
+
270
+ Args:
271
+ registration_institution_id: Assignment ID to delete.
272
+
273
+ Returns:
274
+ dict with deleted (bool), registration_institution_id, deleted_assignment,
275
+ audit_id.
276
+ """
277
+ # Pre-flight validation
278
+ if not registration_institution_id:
279
+ raise ToolError(
280
+ "Cannot delete registration institution: "
281
+ "'registration_institution_id' is required."
282
+ )
283
+
284
+ # Get user email for audit
285
+ try:
286
+ user_email = get_current_user_email()
287
+ except NotAuthenticatedError:
288
+ raise ToolError(
289
+ "Authentication required to delete registration institution assignment. "
290
+ "Use 'auth_login' to authenticate first."
291
+ )
292
+
293
+ audit_logger = AuditLogger()
294
+
295
+ try:
296
+ async with BPAClient() as client:
297
+ # Capture current state for rollback
298
+ try:
299
+ current_state = await client.get(
300
+ "/registration_institution/{registration_institution_id}",
301
+ path_params={
302
+ "registration_institution_id": registration_institution_id
303
+ },
304
+ )
305
+ except BPANotFoundError:
306
+ raise ToolError(
307
+ f"Registration institution '{registration_institution_id}' "
308
+ "not found."
309
+ )
310
+
311
+ # Create PENDING audit record
312
+ audit_id = await audit_logger.record_pending(
313
+ user_email=user_email,
314
+ operation_type="delete",
315
+ object_type="registration_institution",
316
+ object_id=str(registration_institution_id),
317
+ params={"registration_institution_id": registration_institution_id},
318
+ )
319
+
320
+ # Save rollback state separately
321
+ await audit_logger.save_rollback_state(
322
+ audit_id=audit_id,
323
+ object_type="registration_institution",
324
+ object_id=str(registration_institution_id),
325
+ previous_state=current_state,
326
+ )
327
+
328
+ # Execute delete
329
+ try:
330
+ await client.delete(
331
+ "/registration_institution/{registration_institution_id}",
332
+ path_params={
333
+ "registration_institution_id": registration_institution_id
334
+ },
335
+ )
336
+
337
+ # Mark audit success
338
+ await audit_logger.mark_success(
339
+ audit_id=audit_id,
340
+ result={"deleted": True},
341
+ )
342
+
343
+ except Exception as e:
344
+ # Mark audit failed
345
+ await audit_logger.mark_failed(
346
+ audit_id=audit_id,
347
+ error_message=str(e),
348
+ )
349
+ raise
350
+
351
+ except ToolError:
352
+ raise
353
+ except BPAClientError as e:
354
+ raise translate_error(e, resource_type="registration_institution")
355
+
356
+ return {
357
+ "deleted": True,
358
+ "registration_institution_id": registration_institution_id,
359
+ "deleted_assignment": _transform_response(current_state),
360
+ "audit_id": audit_id,
361
+ }
362
+
363
+
364
+ @large_response_handler(
365
+ threshold_bytes=50 * 1024, # 50KB threshold for list tools
366
+ navigation={
367
+ "list_all": "jq '.assignments'",
368
+ "by_registration": "jq '.assignments[] | select(.registration_id==\"x\")'",
369
+ },
370
+ )
371
+ async def registrationinstitution_list_by_institution(
372
+ institution_id: str,
373
+ ) -> dict[str, Any]:
374
+ """List registration assignments for an institution.
375
+
376
+ Large responses (>50KB) are saved to file with navigation hints.
377
+
378
+ Args:
379
+ institution_id: Institution ID to list registrations for.
380
+
381
+ Returns:
382
+ dict with assignments, institution_id, total.
383
+ """
384
+ if not institution_id:
385
+ raise ToolError("Cannot list by institution: 'institution_id' is required.")
386
+
387
+ try:
388
+ async with BPAClient() as client:
389
+ try:
390
+ data = await client.get_list(
391
+ "/registration_institution_by_institution/{institution_id}",
392
+ path_params={"institution_id": institution_id},
393
+ resource_type="registration_institution",
394
+ )
395
+ except BPANotFoundError:
396
+ # Institution may have no assignments
397
+ data = []
398
+ except BPAClientError as e:
399
+ raise translate_error(e, resource_type="registration_institution")
400
+
401
+ # Transform to consistent output format
402
+ assignments = [_transform_response(item) for item in data]
403
+
404
+ return {
405
+ "assignments": assignments,
406
+ "institution_id": institution_id,
407
+ "total": len(assignments),
408
+ }
409
+
410
+
411
+ async def institution_discover(
412
+ sample_size: int = 50,
413
+ ) -> dict[str, Any]:
414
+ """Discover institution IDs by scanning existing registrations.
415
+
416
+ BPA lacks a direct institution list endpoint. This queries registrations
417
+ sequentially, so response time scales with sample_size.
418
+
419
+ Args:
420
+ sample_size: Registrations to sample (default 50).
421
+
422
+ Returns:
423
+ dict with institutions, total, registrations_scanned,
424
+ registrations_with_institutions, message.
425
+ """
426
+ try:
427
+ async with BPAClient() as client:
428
+ # Get list of registrations
429
+ try:
430
+ registrations = await client.get_list(
431
+ "/registration",
432
+ resource_type="registration",
433
+ )
434
+ except BPANotFoundError:
435
+ return {
436
+ "institutions": [],
437
+ "total": 0,
438
+ "registrations_scanned": 0,
439
+ "registrations_with_institutions": 0,
440
+ "message": "No registrations found in the system.",
441
+ }
442
+
443
+ # Limit to sample size
444
+ sample = registrations[:sample_size]
445
+
446
+ # Collect unique institution IDs
447
+ institution_ids: set[str] = set()
448
+ registrations_with_institutions = 0
449
+
450
+ for reg in sample:
451
+ reg_id = reg.get("id")
452
+ if not reg_id:
453
+ continue
454
+
455
+ try:
456
+ assignments = await client.get_list(
457
+ "/registration/{registration_id}/registration_institution",
458
+ path_params={"registration_id": reg_id},
459
+ resource_type="registration_institution",
460
+ )
461
+
462
+ if assignments:
463
+ registrations_with_institutions += 1
464
+ for assignment in assignments:
465
+ inst_id = assignment.get("institutionId")
466
+ if inst_id:
467
+ institution_ids.add(inst_id)
468
+ except BPANotFoundError:
469
+ # Registration may have been deleted
470
+ continue
471
+ except Exception:
472
+ # Skip problematic registrations
473
+ continue
474
+
475
+ institutions_list = sorted(institution_ids)
476
+
477
+ if not institutions_list:
478
+ message = (
479
+ f"Scanned {len(sample)} registrations but found no institution "
480
+ "assignments. Institution IDs may need to be obtained from "
481
+ "your BPA administrator."
482
+ )
483
+ else:
484
+ message = (
485
+ f"Found {len(institutions_list)} unique institution(s) from "
486
+ f"{registrations_with_institutions} assigned registrations. "
487
+ f"Use any of these IDs with registrationinstitution_create."
488
+ )
489
+
490
+ return {
491
+ "institutions": institutions_list,
492
+ "total": len(institutions_list),
493
+ "registrations_scanned": len(sample),
494
+ "registrations_with_institutions": registrations_with_institutions,
495
+ "message": message,
496
+ }
497
+
498
+ except BPAClientError as e:
499
+ raise translate_error(e, resource_type="institution")
500
+
501
+
502
+ async def institution_create(
503
+ name: str,
504
+ short_name: str,
505
+ url: str | None = None,
506
+ ) -> dict[str, Any]:
507
+ """Create institution in Keycloak. Audited write operation.
508
+
509
+ Creates Keycloak group under institutions parent. Configure parent via
510
+ KEYCLOAK_INSTITUTIONS_GROUP_ID env var.
511
+
512
+ Args:
513
+ name: Institution display name.
514
+ short_name: Short name/abbreviation.
515
+ url: Optional website URL.
516
+
517
+ Returns:
518
+ dict with id, name, short_name, url, path, audit_id.
519
+ """
520
+ # Pre-flight validation
521
+ if not name or not name.strip():
522
+ raise ToolError("Cannot create institution: 'name' is required.")
523
+ if not short_name or not short_name.strip():
524
+ raise ToolError("Cannot create institution: 'short_name' is required.")
525
+
526
+ name = name.strip()
527
+ short_name = short_name.strip()
528
+
529
+ # Get user email for audit
530
+ try:
531
+ user_email = get_current_user_email()
532
+ except NotAuthenticatedError:
533
+ raise ToolError(
534
+ "Authentication required to create institution. "
535
+ "Use 'auth_login' to authenticate first."
536
+ )
537
+
538
+ # Get token manager for Keycloak API calls
539
+ token_manager = get_token_manager()
540
+
541
+ # Load config to get Keycloak URL and realm
542
+ config = load_config()
543
+ if not config.keycloak_url or not config.keycloak_realm:
544
+ raise ToolError(
545
+ "Keycloak configuration required for institution management. "
546
+ "Set KEYCLOAK_URL and KEYCLOAK_REALM environment variables."
547
+ )
548
+
549
+ # Get parent group ID from env or use default
550
+ parent_group_id = os.environ.get(
551
+ "KEYCLOAK_INSTITUTIONS_GROUP_ID", DEFAULT_INSTITUTIONS_PARENT_GROUP
552
+ )
553
+
554
+ audit_logger = AuditLogger()
555
+
556
+ # Prepare request payload
557
+ attributes: dict[str, list[str]] = {
558
+ "shortName": [short_name],
559
+ }
560
+ if url:
561
+ attributes["url"] = [url]
562
+
563
+ payload = {
564
+ "name": name,
565
+ "attributes": attributes,
566
+ }
567
+
568
+ # Create PENDING audit record
569
+ audit_id = await audit_logger.record_pending(
570
+ user_email=user_email,
571
+ operation_type="create",
572
+ object_type="institution",
573
+ params={
574
+ "name": name,
575
+ "short_name": short_name,
576
+ "url": url,
577
+ "parent_group_id": parent_group_id,
578
+ },
579
+ )
580
+
581
+ try:
582
+ # Get access token
583
+ access_token = await token_manager.get_access_token()
584
+
585
+ # Build Keycloak Admin API URL
586
+ keycloak_url = (
587
+ f"{config.keycloak_url}/admin/realms/{config.keycloak_realm}"
588
+ f"/groups/{parent_group_id}/children"
589
+ )
590
+
591
+ # Make the request to Keycloak
592
+ async with httpx.AsyncClient() as client:
593
+ response = await client.post(
594
+ keycloak_url,
595
+ json=payload,
596
+ headers={
597
+ "Authorization": f"Bearer {access_token}",
598
+ "Content-Type": "application/json",
599
+ },
600
+ timeout=30.0,
601
+ )
602
+
603
+ if response.status_code == 409:
604
+ await audit_logger.mark_failed(audit_id, "Institution already exists")
605
+ raise ToolError(
606
+ f"Institution '{name}' already exists. "
607
+ "Use 'institution_discover' to find existing institutions."
608
+ )
609
+
610
+ if response.status_code == 403:
611
+ await audit_logger.mark_failed(audit_id, "Permission denied")
612
+ raise ToolError(
613
+ "Permission denied to create institution. "
614
+ "Ensure your account has Keycloak admin privileges."
615
+ )
616
+
617
+ if response.status_code == 404:
618
+ await audit_logger.mark_failed(audit_id, "Parent group not found")
619
+ raise ToolError(
620
+ f"Institutions parent group '{parent_group_id}' not found. "
621
+ "Check KEYCLOAK_INSTITUTIONS_GROUP_ID configuration."
622
+ )
623
+
624
+ response.raise_for_status()
625
+ result = response.json()
626
+
627
+ # Mark audit success
628
+ await audit_logger.mark_success(
629
+ audit_id=audit_id,
630
+ result={"id": result.get("id"), **result},
631
+ )
632
+
633
+ # Return formatted response
634
+ return {
635
+ "id": result.get("id"),
636
+ "name": result.get("name"),
637
+ "short_name": short_name,
638
+ "url": url,
639
+ "path": result.get("path"),
640
+ "audit_id": audit_id,
641
+ "message": (
642
+ f"Institution '{name}' created successfully. "
643
+ "Use this ID with registrationinstitution_create to assign."
644
+ ),
645
+ }
646
+
647
+ except ToolError:
648
+ raise
649
+ except httpx.HTTPStatusError as e:
650
+ error_msg = f"Keycloak API error: {e.response.status_code}"
651
+ try:
652
+ error_detail = e.response.json()
653
+ if "errorMessage" in error_detail:
654
+ error_msg = f"Keycloak error: {error_detail['errorMessage']}"
655
+ except Exception:
656
+ pass
657
+ await audit_logger.mark_failed(audit_id, error_msg)
658
+ raise ToolError(error_msg)
659
+ except httpx.RequestError as e:
660
+ error_msg = f"Network error connecting to Keycloak: {e}"
661
+ await audit_logger.mark_failed(audit_id, error_msg)
662
+ raise ToolError(error_msg)
663
+ except Exception as e:
664
+ await audit_logger.mark_failed(audit_id, str(e))
665
+ raise ToolError(f"Failed to create institution: {e}")
666
+
667
+
668
+ def register_registration_institution_tools(mcp_server: Any) -> None:
669
+ """Register registration institution tools with the MCP server.
670
+
671
+ Args:
672
+ mcp_server: The FastMCP server instance.
673
+ """
674
+ mcp_server.tool()(registrationinstitution_list)
675
+ mcp_server.tool()(registrationinstitution_get)
676
+ mcp_server.tool()(registrationinstitution_create)
677
+ mcp_server.tool()(registrationinstitution_delete)
678
+ mcp_server.tool()(registrationinstitution_list_by_institution)
679
+ mcp_server.tool()(institution_discover)
680
+ mcp_server.tool()(institution_create)