mcp-eregistrations-bpa 0.8.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,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)
|