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,897 @@
|
|
|
1
|
+
"""MCP tools for BPA registration operations.
|
|
2
|
+
|
|
3
|
+
This module provides tools for listing, retrieving, creating, deleting,
|
|
4
|
+
activating, and linking BPA registrations.
|
|
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
|
+
API Endpoints used:
|
|
13
|
+
- GET /registration - List all registrations
|
|
14
|
+
- GET /registration/{id} - Get registration by ID
|
|
15
|
+
- POST /registration - Create registration (with serviceId in body)
|
|
16
|
+
- DELETE /registration/{registration_id} - Delete registration
|
|
17
|
+
- POST /service_registration/{service_id}/{registration_id} - Link to service
|
|
18
|
+
- PUT /service/{service_id}/registration - Activate/deactivate registration
|
|
19
|
+
|
|
20
|
+
Note: The BPA API is service-centric. To get fields/determinants, use
|
|
21
|
+
the service-level endpoints (field_list, determinant_list with service_id).
|
|
22
|
+
|
|
23
|
+
Important: After creating a registration, you need TWO operations:
|
|
24
|
+
1. serviceregistration_link - Links registration to service AND activates it
|
|
25
|
+
2. registrationinstitution_create - Assigns institution (required for publishing)
|
|
26
|
+
|
|
27
|
+
Design principle: Elements are active by default upon creation/linking.
|
|
28
|
+
Use registration_activate with active=False to deactivate if needed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
36
|
+
|
|
37
|
+
from mcp_eregistrations_bpa.audit.context import (
|
|
38
|
+
NotAuthenticatedError,
|
|
39
|
+
get_current_user_email,
|
|
40
|
+
)
|
|
41
|
+
from mcp_eregistrations_bpa.audit.logger import AuditLogger
|
|
42
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
43
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
44
|
+
BPAClientError,
|
|
45
|
+
BPANotFoundError,
|
|
46
|
+
translate_error,
|
|
47
|
+
)
|
|
48
|
+
from mcp_eregistrations_bpa.tools.large_response import large_response_handler
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"registration_list",
|
|
52
|
+
"registration_get",
|
|
53
|
+
"registration_create",
|
|
54
|
+
"registration_delete",
|
|
55
|
+
"registration_activate",
|
|
56
|
+
"serviceregistration_link",
|
|
57
|
+
"register_registration_tools",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@large_response_handler(
|
|
62
|
+
threshold_bytes=50 * 1024, # 50KB threshold for list tools
|
|
63
|
+
navigation={
|
|
64
|
+
"list_all": "jq '.registrations'",
|
|
65
|
+
"find_by_name": "jq '.registrations[] | select(.name | contains(\"search\"))'",
|
|
66
|
+
"find_by_service": "jq '.registrations[] | select(.service_id == \"UUID\")'",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
async def registration_list(
|
|
70
|
+
service_id: str | int | None = None,
|
|
71
|
+
limit: int = 50,
|
|
72
|
+
offset: int = 0,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""List all BPA registrations.
|
|
75
|
+
|
|
76
|
+
Note: BPA extracts registrations from service response when filtering
|
|
77
|
+
(no server-side filter endpoint exists).
|
|
78
|
+
Large responses (>50KB) are saved to file with navigation hints.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
service_id: Optional service ID to filter registrations by.
|
|
82
|
+
limit: Maximum number of registrations to return (default: 50).
|
|
83
|
+
offset: Number of registrations to skip (default: 0).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
dict with registrations (id, name, service_id), total, has_more.
|
|
87
|
+
"""
|
|
88
|
+
# Normalize limit and offset
|
|
89
|
+
if limit <= 0:
|
|
90
|
+
limit = 50
|
|
91
|
+
if offset < 0:
|
|
92
|
+
offset = 0
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
async with BPAClient() as client:
|
|
96
|
+
if service_id is not None:
|
|
97
|
+
# BPA API embeds registrations in service response
|
|
98
|
+
# Note: These are registration references (id, name only)
|
|
99
|
+
try:
|
|
100
|
+
service_data = await client.get(
|
|
101
|
+
"/service/{id}",
|
|
102
|
+
path_params={"id": service_id},
|
|
103
|
+
resource_type="service",
|
|
104
|
+
resource_id=service_id,
|
|
105
|
+
)
|
|
106
|
+
registrations_data = service_data.get("registrations", [])
|
|
107
|
+
except BPANotFoundError:
|
|
108
|
+
raise ToolError(
|
|
109
|
+
f"Service '{service_id}' not found. "
|
|
110
|
+
"Use 'service_list' to see available services."
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# Use global registration list
|
|
114
|
+
registrations_data = await client.get_list(
|
|
115
|
+
"/registration",
|
|
116
|
+
resource_type="registration",
|
|
117
|
+
)
|
|
118
|
+
except ToolError:
|
|
119
|
+
raise
|
|
120
|
+
except BPAClientError as e:
|
|
121
|
+
raise translate_error(e, resource_type="registration")
|
|
122
|
+
|
|
123
|
+
# Transform to consistent output format
|
|
124
|
+
registrations = []
|
|
125
|
+
for reg in registrations_data:
|
|
126
|
+
registrations.append(
|
|
127
|
+
{
|
|
128
|
+
"id": reg.get("id"),
|
|
129
|
+
"name": reg.get("name"),
|
|
130
|
+
"service_id": reg.get("serviceId")
|
|
131
|
+
if service_id is None
|
|
132
|
+
else service_id,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Calculate total before pagination
|
|
137
|
+
total = len(registrations)
|
|
138
|
+
|
|
139
|
+
# Apply pagination
|
|
140
|
+
paginated_registrations = registrations[offset : offset + limit]
|
|
141
|
+
|
|
142
|
+
# Calculate has_more
|
|
143
|
+
has_more = (offset + limit) < total
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"registrations": paginated_registrations,
|
|
147
|
+
"total": total,
|
|
148
|
+
"has_more": has_more,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def registration_get(registration_id: str | int) -> dict[str, Any]:
|
|
153
|
+
"""Get details of a BPA registration by ID.
|
|
154
|
+
|
|
155
|
+
Returns registration details including linked service info.
|
|
156
|
+
Note: To get fields/determinants, use field_list(service_id) and
|
|
157
|
+
determinant_list(service_id) with the service_id from this registration.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
registration_id: The unique identifier of the registration.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
dict: Registration details including:
|
|
164
|
+
- id, name, description, status
|
|
165
|
+
- service_id: The parent service ID
|
|
166
|
+
- service: Linked service summary (id, name)
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
async with BPAClient() as client:
|
|
170
|
+
# Get registration details
|
|
171
|
+
try:
|
|
172
|
+
registration_data = await client.get(
|
|
173
|
+
"/registration/{id}",
|
|
174
|
+
path_params={"id": registration_id},
|
|
175
|
+
resource_type="registration",
|
|
176
|
+
resource_id=registration_id,
|
|
177
|
+
)
|
|
178
|
+
except BPANotFoundError:
|
|
179
|
+
raise ToolError(
|
|
180
|
+
f"Registration '{registration_id}' not found. "
|
|
181
|
+
"Use 'registration_list' to see available registrations."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Get linked service info (if exists)
|
|
185
|
+
service_id = registration_data.get("serviceId")
|
|
186
|
+
service_data: dict[str, Any] = {}
|
|
187
|
+
|
|
188
|
+
# If serviceId not in registration response, search for parent service
|
|
189
|
+
# This mirrors the pattern in registration_delete
|
|
190
|
+
if not service_id:
|
|
191
|
+
services = await client.get_list("/service", resource_type="service")
|
|
192
|
+
for svc in services:
|
|
193
|
+
svc_id = svc.get("id")
|
|
194
|
+
try:
|
|
195
|
+
svc_detail = await client.get(
|
|
196
|
+
"/service/{id}",
|
|
197
|
+
path_params={"id": svc_id},
|
|
198
|
+
resource_type="service",
|
|
199
|
+
resource_id=svc_id,
|
|
200
|
+
)
|
|
201
|
+
for reg in svc_detail.get("registrations", []):
|
|
202
|
+
if str(reg.get("id")) == str(registration_id):
|
|
203
|
+
service_id = svc_id
|
|
204
|
+
service_data = svc_detail
|
|
205
|
+
break
|
|
206
|
+
except BPANotFoundError:
|
|
207
|
+
continue
|
|
208
|
+
if service_id:
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# If serviceId was in registration response, fetch service details
|
|
212
|
+
if service_id and not service_data:
|
|
213
|
+
try:
|
|
214
|
+
service_data = await client.get(
|
|
215
|
+
"/service/{id}",
|
|
216
|
+
path_params={"id": service_id},
|
|
217
|
+
resource_type="service",
|
|
218
|
+
resource_id=service_id,
|
|
219
|
+
)
|
|
220
|
+
except BPANotFoundError:
|
|
221
|
+
pass
|
|
222
|
+
except ToolError:
|
|
223
|
+
raise
|
|
224
|
+
except BPAClientError as e:
|
|
225
|
+
raise translate_error(
|
|
226
|
+
e, resource_type="registration", resource_id=registration_id
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
service = {}
|
|
230
|
+
if service_data:
|
|
231
|
+
service = {
|
|
232
|
+
"id": service_data.get("id"),
|
|
233
|
+
"name": service_data.get("name"),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"id": registration_data.get("id"),
|
|
238
|
+
"name": registration_data.get("name"),
|
|
239
|
+
"description": registration_data.get("description"),
|
|
240
|
+
"status": registration_data.get("status"),
|
|
241
|
+
"service_id": service_id,
|
|
242
|
+
"service": service,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _validate_registration_create_params(
|
|
247
|
+
service_id: str | int,
|
|
248
|
+
name: str,
|
|
249
|
+
short_name: str,
|
|
250
|
+
key: str,
|
|
251
|
+
description: str | None,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Validate registration_create parameters (pre-flight).
|
|
254
|
+
|
|
255
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
256
|
+
No audit record is created for validation failures.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
service_id: Parent service ID (required).
|
|
260
|
+
name: Registration name (required).
|
|
261
|
+
short_name: Short name for the registration (required).
|
|
262
|
+
key: Unique key identifier for the registration (required).
|
|
263
|
+
description: Registration description (optional).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
dict: Validated parameters ready for API call.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ToolError: If validation fails.
|
|
270
|
+
"""
|
|
271
|
+
errors = []
|
|
272
|
+
|
|
273
|
+
if not service_id:
|
|
274
|
+
errors.append("'service_id' is required")
|
|
275
|
+
|
|
276
|
+
if not name or not name.strip():
|
|
277
|
+
errors.append("'name' is required and cannot be empty")
|
|
278
|
+
|
|
279
|
+
if name and len(name.strip()) > 255:
|
|
280
|
+
errors.append("'name' must be 255 characters or less")
|
|
281
|
+
|
|
282
|
+
if not short_name or not short_name.strip():
|
|
283
|
+
errors.append("'short_name' is required and cannot be empty")
|
|
284
|
+
|
|
285
|
+
if short_name and len(short_name.strip()) > 50:
|
|
286
|
+
errors.append("'short_name' must be 50 characters or less")
|
|
287
|
+
|
|
288
|
+
if not key or not key.strip():
|
|
289
|
+
errors.append("'key' is required and cannot be empty")
|
|
290
|
+
|
|
291
|
+
if key and len(key.strip()) > 100:
|
|
292
|
+
errors.append("'key' must be 100 characters or less")
|
|
293
|
+
|
|
294
|
+
if errors:
|
|
295
|
+
error_msg = "; ".join(errors)
|
|
296
|
+
raise ToolError(
|
|
297
|
+
f"Cannot create registration: {error_msg}. "
|
|
298
|
+
"Provide valid 'service_id', 'name', 'short_name', and 'key' parameters."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
params: dict[str, Any] = {
|
|
302
|
+
"name": name.strip(),
|
|
303
|
+
"shortName": short_name.strip(),
|
|
304
|
+
"key": key.strip(),
|
|
305
|
+
"serviceId": str(service_id),
|
|
306
|
+
"active": True, # Elements are active by default
|
|
307
|
+
"mandatorySelectedDefault": True,
|
|
308
|
+
}
|
|
309
|
+
if description:
|
|
310
|
+
params["description"] = description.strip()
|
|
311
|
+
|
|
312
|
+
return params
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _validate_registration_delete_params(
|
|
316
|
+
registration_id: str | int,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Validate registration_delete parameters (pre-flight).
|
|
319
|
+
|
|
320
|
+
Raises ToolError if validation fails.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
registration_id: Registration ID to delete (required).
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ToolError: If validation fails.
|
|
327
|
+
"""
|
|
328
|
+
if not registration_id:
|
|
329
|
+
raise ToolError(
|
|
330
|
+
"Cannot delete registration: 'registration_id' is required. "
|
|
331
|
+
"Use 'registration_list' to find valid registration IDs."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def registration_create(
|
|
336
|
+
service_id: str | int,
|
|
337
|
+
name: str,
|
|
338
|
+
short_name: str,
|
|
339
|
+
key: str,
|
|
340
|
+
description: str | None = None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
"""Create registration in a service. Audited write operation.
|
|
343
|
+
|
|
344
|
+
Requires registrationinstitution_create() for frontend visibility.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
service_id: Parent service ID.
|
|
348
|
+
name: Registration name.
|
|
349
|
+
short_name: Short name.
|
|
350
|
+
key: Unique key identifier.
|
|
351
|
+
description: Optional description.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
dict with id, name, short_name, key, service_id, active, audit_id.
|
|
355
|
+
"""
|
|
356
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
357
|
+
validated_params = _validate_registration_create_params(
|
|
358
|
+
service_id, name, short_name, key, description
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Get authenticated user for audit (before any API calls)
|
|
362
|
+
try:
|
|
363
|
+
user_email = get_current_user_email()
|
|
364
|
+
except NotAuthenticatedError as e:
|
|
365
|
+
raise ToolError(str(e))
|
|
366
|
+
|
|
367
|
+
# Use single BPAClient connection for all operations
|
|
368
|
+
try:
|
|
369
|
+
async with BPAClient() as client:
|
|
370
|
+
# Verify parent service exists before creating audit record
|
|
371
|
+
try:
|
|
372
|
+
await client.get(
|
|
373
|
+
"/service/{id}",
|
|
374
|
+
path_params={"id": service_id},
|
|
375
|
+
resource_type="service",
|
|
376
|
+
resource_id=service_id,
|
|
377
|
+
)
|
|
378
|
+
except BPANotFoundError:
|
|
379
|
+
raise ToolError(
|
|
380
|
+
f"Cannot create registration: Service '{service_id}' not found. "
|
|
381
|
+
"Use 'service_list' to see available services."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
385
|
+
audit_logger = AuditLogger()
|
|
386
|
+
audit_id = await audit_logger.record_pending(
|
|
387
|
+
user_email=user_email,
|
|
388
|
+
operation_type="create",
|
|
389
|
+
object_type="registration",
|
|
390
|
+
params={
|
|
391
|
+
"service_id": str(service_id),
|
|
392
|
+
**validated_params,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
registration_data = await client.post(
|
|
398
|
+
"/registration",
|
|
399
|
+
json=validated_params,
|
|
400
|
+
resource_type="registration",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
404
|
+
created_id = registration_data.get("id")
|
|
405
|
+
await audit_logger.save_rollback_state(
|
|
406
|
+
audit_id=audit_id,
|
|
407
|
+
object_type="registration",
|
|
408
|
+
object_id=str(created_id),
|
|
409
|
+
previous_state={
|
|
410
|
+
"id": created_id,
|
|
411
|
+
"name": registration_data.get("name"),
|
|
412
|
+
"shortName": registration_data.get("shortName"),
|
|
413
|
+
"key": registration_data.get("key"),
|
|
414
|
+
"description": registration_data.get("description"),
|
|
415
|
+
"serviceId": str(service_id),
|
|
416
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Mark audit as success
|
|
421
|
+
await audit_logger.mark_success(
|
|
422
|
+
audit_id,
|
|
423
|
+
result={
|
|
424
|
+
"registration_id": created_id,
|
|
425
|
+
"name": registration_data.get("name"),
|
|
426
|
+
"service_id": str(service_id),
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"id": created_id,
|
|
432
|
+
"name": registration_data.get("name"),
|
|
433
|
+
"short_name": registration_data.get("shortName"),
|
|
434
|
+
"key": registration_data.get("key"),
|
|
435
|
+
"description": registration_data.get("description"),
|
|
436
|
+
"status": registration_data.get("status"),
|
|
437
|
+
"active": registration_data.get("active", True),
|
|
438
|
+
"service_id": service_id,
|
|
439
|
+
"audit_id": audit_id,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
except BPAClientError as e:
|
|
443
|
+
# Mark audit as failed
|
|
444
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
445
|
+
raise translate_error(e, resource_type="registration")
|
|
446
|
+
|
|
447
|
+
except ToolError:
|
|
448
|
+
raise
|
|
449
|
+
except BPAClientError as e:
|
|
450
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
async def registration_delete(
|
|
454
|
+
registration_id: str | int,
|
|
455
|
+
) -> dict[str, Any]:
|
|
456
|
+
"""Delete a BPA registration. Audited write operation.
|
|
457
|
+
|
|
458
|
+
Note: BPA may return "Permission denied" due to server-side workflow permissions.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
registration_id: Registration ID to delete.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
dict with deleted (bool), registration_id, deleted_registration, audit_id.
|
|
465
|
+
"""
|
|
466
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
467
|
+
_validate_registration_delete_params(registration_id)
|
|
468
|
+
|
|
469
|
+
# Get authenticated user for audit
|
|
470
|
+
try:
|
|
471
|
+
user_email = get_current_user_email()
|
|
472
|
+
except NotAuthenticatedError as e:
|
|
473
|
+
raise ToolError(str(e))
|
|
474
|
+
|
|
475
|
+
# Capture current state for rollback BEFORE making changes
|
|
476
|
+
try:
|
|
477
|
+
async with BPAClient() as client:
|
|
478
|
+
try:
|
|
479
|
+
previous_state = await client.get(
|
|
480
|
+
"/registration/{id}",
|
|
481
|
+
path_params={"id": registration_id},
|
|
482
|
+
resource_type="registration",
|
|
483
|
+
resource_id=registration_id,
|
|
484
|
+
)
|
|
485
|
+
except BPANotFoundError:
|
|
486
|
+
raise ToolError(
|
|
487
|
+
f"Registration '{registration_id}' not found. "
|
|
488
|
+
"Use 'registration_list' to see available registrations."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# BPA API doesn't return serviceId in registration GET response
|
|
492
|
+
# We need to find the parent service by checking all services
|
|
493
|
+
service_id = previous_state.get("serviceId")
|
|
494
|
+
if not service_id:
|
|
495
|
+
# Search for the service containing this registration
|
|
496
|
+
services = await client.get_list("/service", resource_type="service")
|
|
497
|
+
for svc in services:
|
|
498
|
+
svc_id = svc.get("id")
|
|
499
|
+
try:
|
|
500
|
+
svc_detail = await client.get(
|
|
501
|
+
"/service/{id}",
|
|
502
|
+
path_params={"id": svc_id},
|
|
503
|
+
resource_type="service",
|
|
504
|
+
resource_id=svc_id,
|
|
505
|
+
)
|
|
506
|
+
for reg in svc_detail.get("registrations", []):
|
|
507
|
+
if str(reg.get("id")) == str(registration_id):
|
|
508
|
+
service_id = svc_id
|
|
509
|
+
break
|
|
510
|
+
except BPANotFoundError:
|
|
511
|
+
continue
|
|
512
|
+
if service_id:
|
|
513
|
+
break
|
|
514
|
+
|
|
515
|
+
except ToolError:
|
|
516
|
+
raise
|
|
517
|
+
except BPAClientError as e:
|
|
518
|
+
raise translate_error(
|
|
519
|
+
e, resource_type="registration", resource_id=registration_id
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Build complete previous state for rollback (includes service_id)
|
|
523
|
+
rollback_previous_state = {
|
|
524
|
+
"id": previous_state.get("id"),
|
|
525
|
+
"name": previous_state.get("name"),
|
|
526
|
+
"shortName": previous_state.get("shortName"),
|
|
527
|
+
"key": previous_state.get("key"),
|
|
528
|
+
"description": previous_state.get("description"),
|
|
529
|
+
"serviceId": service_id, # Now we have the service_id
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# Normalize previous_state to snake_case for response
|
|
533
|
+
normalized_previous_state = {
|
|
534
|
+
"id": previous_state.get("id"),
|
|
535
|
+
"name": previous_state.get("name"),
|
|
536
|
+
"description": previous_state.get("description"),
|
|
537
|
+
"service_id": service_id,
|
|
538
|
+
"status": previous_state.get("status"),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
542
|
+
audit_logger = AuditLogger()
|
|
543
|
+
audit_id = await audit_logger.record_pending(
|
|
544
|
+
user_email=user_email,
|
|
545
|
+
operation_type="delete",
|
|
546
|
+
object_type="registration",
|
|
547
|
+
object_id=str(registration_id),
|
|
548
|
+
params={"service_id": service_id}, # Include service_id for rollback
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Save rollback state for undo capability (recreate on rollback)
|
|
552
|
+
await audit_logger.save_rollback_state(
|
|
553
|
+
audit_id=audit_id,
|
|
554
|
+
object_type="registration",
|
|
555
|
+
object_id=str(registration_id),
|
|
556
|
+
previous_state=rollback_previous_state,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
async with BPAClient() as client:
|
|
561
|
+
await client.delete(
|
|
562
|
+
"/registration/{id}",
|
|
563
|
+
path_params={"id": registration_id},
|
|
564
|
+
resource_type="registration",
|
|
565
|
+
resource_id=registration_id,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Mark audit as success
|
|
569
|
+
await audit_logger.mark_success(
|
|
570
|
+
audit_id,
|
|
571
|
+
result={
|
|
572
|
+
"deleted": True,
|
|
573
|
+
"registration_id": str(registration_id),
|
|
574
|
+
},
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"deleted": True,
|
|
579
|
+
"registration_id": registration_id,
|
|
580
|
+
"deleted_registration": {
|
|
581
|
+
"id": normalized_previous_state["id"],
|
|
582
|
+
"name": normalized_previous_state["name"],
|
|
583
|
+
"service_id": normalized_previous_state["service_id"],
|
|
584
|
+
},
|
|
585
|
+
"audit_id": audit_id,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
except BPAClientError as e:
|
|
589
|
+
# Mark audit as failed
|
|
590
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
591
|
+
raise translate_error(
|
|
592
|
+
e, resource_type="registration", resource_id=registration_id
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def registration_activate(
|
|
597
|
+
service_id: str | int,
|
|
598
|
+
registration_id: str | int,
|
|
599
|
+
active: bool = True,
|
|
600
|
+
) -> dict[str, Any]:
|
|
601
|
+
"""Activate or deactivate a registration. Audited write operation.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
service_id: Service containing the registration.
|
|
605
|
+
registration_id: Registration to activate/deactivate.
|
|
606
|
+
active: True to activate, False to deactivate (default: True).
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
dict with service_id, registration_id, registration_name, active, audit_id.
|
|
610
|
+
"""
|
|
611
|
+
# Pre-flight validation
|
|
612
|
+
errors = []
|
|
613
|
+
if not service_id:
|
|
614
|
+
errors.append("'service_id' is required")
|
|
615
|
+
if not registration_id:
|
|
616
|
+
errors.append("'registration_id' is required")
|
|
617
|
+
if errors:
|
|
618
|
+
error_msg = "; ".join(errors)
|
|
619
|
+
raise ToolError(
|
|
620
|
+
f"Cannot activate/deactivate registration: {error_msg}. "
|
|
621
|
+
"Provide valid 'service_id' and 'registration_id' parameters."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Get authenticated user for audit
|
|
625
|
+
try:
|
|
626
|
+
user_email = get_current_user_email()
|
|
627
|
+
except NotAuthenticatedError as e:
|
|
628
|
+
raise ToolError(str(e))
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
async with BPAClient() as client:
|
|
632
|
+
# Verify service exists
|
|
633
|
+
try:
|
|
634
|
+
await client.get(
|
|
635
|
+
"/service/{id}",
|
|
636
|
+
path_params={"id": service_id},
|
|
637
|
+
resource_type="service",
|
|
638
|
+
resource_id=service_id,
|
|
639
|
+
)
|
|
640
|
+
except BPANotFoundError:
|
|
641
|
+
raise ToolError(
|
|
642
|
+
f"Cannot activate registration: Service '{service_id}' not found. "
|
|
643
|
+
"Use 'service_list' to see available services."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Verify registration exists and get its details
|
|
647
|
+
try:
|
|
648
|
+
registration_data = await client.get(
|
|
649
|
+
"/registration/{id}",
|
|
650
|
+
path_params={"id": registration_id},
|
|
651
|
+
resource_type="registration",
|
|
652
|
+
resource_id=registration_id,
|
|
653
|
+
)
|
|
654
|
+
except BPANotFoundError:
|
|
655
|
+
raise ToolError(
|
|
656
|
+
f"Registration '{registration_id}' not found. "
|
|
657
|
+
"Use 'registration_list' to see available registrations."
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Create audit record BEFORE API call
|
|
661
|
+
audit_logger = AuditLogger()
|
|
662
|
+
audit_id = await audit_logger.record_pending(
|
|
663
|
+
user_email=user_email,
|
|
664
|
+
operation_type="update",
|
|
665
|
+
object_type="registration",
|
|
666
|
+
object_id=str(registration_id),
|
|
667
|
+
params={
|
|
668
|
+
"service_id": str(service_id),
|
|
669
|
+
"registration_id": str(registration_id),
|
|
670
|
+
"active": active,
|
|
671
|
+
},
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
# Build payload - include registration data with active flag
|
|
676
|
+
payload = {
|
|
677
|
+
"id": str(registration_id),
|
|
678
|
+
"name": registration_data.get("name"),
|
|
679
|
+
"shortName": registration_data.get("shortName"),
|
|
680
|
+
"key": registration_data.get("key"),
|
|
681
|
+
"active": active,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
# PUT /service/{service_id}/registration
|
|
685
|
+
await client.put(
|
|
686
|
+
"/service/{service_id}/registration",
|
|
687
|
+
path_params={"service_id": service_id},
|
|
688
|
+
json=payload,
|
|
689
|
+
resource_type="registration",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Save rollback state
|
|
693
|
+
await audit_logger.save_rollback_state(
|
|
694
|
+
audit_id=audit_id,
|
|
695
|
+
object_type="registration",
|
|
696
|
+
object_id=str(registration_id),
|
|
697
|
+
previous_state={
|
|
698
|
+
"service_id": str(service_id),
|
|
699
|
+
"registration_id": str(registration_id),
|
|
700
|
+
"active": not active, # Previous state was opposite
|
|
701
|
+
"_operation": "activate",
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# Mark audit as success
|
|
706
|
+
await audit_logger.mark_success(
|
|
707
|
+
audit_id,
|
|
708
|
+
result={
|
|
709
|
+
"service_id": str(service_id),
|
|
710
|
+
"registration_id": str(registration_id),
|
|
711
|
+
"active": active,
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
"service_id": str(service_id),
|
|
717
|
+
"registration_id": str(registration_id),
|
|
718
|
+
"registration_name": registration_data.get("name"),
|
|
719
|
+
"active": active,
|
|
720
|
+
"audit_id": audit_id,
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
except BPAClientError as e:
|
|
724
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
725
|
+
raise translate_error(e, resource_type="registration")
|
|
726
|
+
|
|
727
|
+
except ToolError:
|
|
728
|
+
raise
|
|
729
|
+
except BPAClientError as e:
|
|
730
|
+
raise translate_error(e, resource_type="registration")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async def serviceregistration_link(
|
|
734
|
+
service_id: str | int,
|
|
735
|
+
registration_id: str | int,
|
|
736
|
+
) -> dict[str, Any]:
|
|
737
|
+
"""Link registration to service and activate it. Audited write operation.
|
|
738
|
+
|
|
739
|
+
Makes registration appear in service's UI. Also use registrationinstitution_create
|
|
740
|
+
to assign institution (required for publishing).
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
service_id: Service to link registration to.
|
|
744
|
+
registration_id: Registration to link.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
dict with service_id, registration_id, service_name, registration_name,
|
|
748
|
+
linked, active, audit_id.
|
|
749
|
+
"""
|
|
750
|
+
# Pre-flight validation
|
|
751
|
+
errors = []
|
|
752
|
+
if not service_id:
|
|
753
|
+
errors.append("'service_id' is required")
|
|
754
|
+
if not registration_id:
|
|
755
|
+
errors.append("'registration_id' is required")
|
|
756
|
+
if errors:
|
|
757
|
+
error_msg = "; ".join(errors)
|
|
758
|
+
raise ToolError(
|
|
759
|
+
f"Cannot link registration to service: {error_msg}. "
|
|
760
|
+
"Provide valid 'service_id' and 'registration_id' parameters."
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Get authenticated user for audit
|
|
764
|
+
try:
|
|
765
|
+
user_email = get_current_user_email()
|
|
766
|
+
except NotAuthenticatedError as e:
|
|
767
|
+
raise ToolError(str(e))
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
async with BPAClient() as client:
|
|
771
|
+
# Verify service exists
|
|
772
|
+
try:
|
|
773
|
+
service_data = await client.get(
|
|
774
|
+
"/service/{id}",
|
|
775
|
+
path_params={"id": service_id},
|
|
776
|
+
resource_type="service",
|
|
777
|
+
resource_id=service_id,
|
|
778
|
+
)
|
|
779
|
+
except BPANotFoundError:
|
|
780
|
+
raise ToolError(
|
|
781
|
+
f"Cannot link registration: Service '{service_id}' not found. "
|
|
782
|
+
"Use 'service_list' to see available services."
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# Verify registration exists
|
|
786
|
+
try:
|
|
787
|
+
registration_data = await client.get(
|
|
788
|
+
"/registration/{id}",
|
|
789
|
+
path_params={"id": registration_id},
|
|
790
|
+
resource_type="registration",
|
|
791
|
+
resource_id=registration_id,
|
|
792
|
+
)
|
|
793
|
+
except BPANotFoundError:
|
|
794
|
+
raise ToolError(
|
|
795
|
+
f"Registration '{registration_id}' not found. "
|
|
796
|
+
"Use 'registration_list' to see available registrations."
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# Create audit record BEFORE API call
|
|
800
|
+
audit_logger = AuditLogger()
|
|
801
|
+
audit_id = await audit_logger.record_pending(
|
|
802
|
+
user_email=user_email,
|
|
803
|
+
operation_type="link",
|
|
804
|
+
object_type="service_registration",
|
|
805
|
+
params={
|
|
806
|
+
"service_id": str(service_id),
|
|
807
|
+
"registration_id": str(registration_id),
|
|
808
|
+
},
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
# POST /service_registration/{service_id}/{registration_id}
|
|
813
|
+
await client.post(
|
|
814
|
+
"/service_registration/{service_id}/{registration_id}",
|
|
815
|
+
path_params={
|
|
816
|
+
"service_id": service_id,
|
|
817
|
+
"registration_id": registration_id,
|
|
818
|
+
},
|
|
819
|
+
json={"responseType": "text"},
|
|
820
|
+
resource_type="service_registration",
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# Save rollback state (for unlink capability)
|
|
824
|
+
await audit_logger.save_rollback_state(
|
|
825
|
+
audit_id=audit_id,
|
|
826
|
+
object_type="service_registration",
|
|
827
|
+
object_id=f"{service_id}_{registration_id}",
|
|
828
|
+
previous_state={
|
|
829
|
+
"service_id": str(service_id),
|
|
830
|
+
"registration_id": str(registration_id),
|
|
831
|
+
"_operation": "link",
|
|
832
|
+
},
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# Activate the registration by default after linking
|
|
836
|
+
# Build payload for activation
|
|
837
|
+
activate_payload = {
|
|
838
|
+
"id": str(registration_id),
|
|
839
|
+
"name": registration_data.get("name"),
|
|
840
|
+
"shortName": registration_data.get("shortName"),
|
|
841
|
+
"key": registration_data.get("key"),
|
|
842
|
+
"active": True,
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
# PUT /service/{service_id}/registration to activate
|
|
846
|
+
await client.put(
|
|
847
|
+
"/service/{service_id}/registration",
|
|
848
|
+
path_params={"service_id": service_id},
|
|
849
|
+
json=activate_payload,
|
|
850
|
+
resource_type="registration",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# Mark audit as success
|
|
854
|
+
await audit_logger.mark_success(
|
|
855
|
+
audit_id,
|
|
856
|
+
result={
|
|
857
|
+
"service_id": str(service_id),
|
|
858
|
+
"registration_id": str(registration_id),
|
|
859
|
+
"linked": True,
|
|
860
|
+
"active": True,
|
|
861
|
+
},
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
"service_id": str(service_id),
|
|
866
|
+
"registration_id": str(registration_id),
|
|
867
|
+
"service_name": service_data.get("name"),
|
|
868
|
+
"registration_name": registration_data.get("name"),
|
|
869
|
+
"linked": True,
|
|
870
|
+
"active": True,
|
|
871
|
+
"audit_id": audit_id,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
except BPAClientError as e:
|
|
875
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
876
|
+
raise translate_error(e, resource_type="service_registration")
|
|
877
|
+
|
|
878
|
+
except ToolError:
|
|
879
|
+
raise
|
|
880
|
+
except BPAClientError as e:
|
|
881
|
+
raise translate_error(e, resource_type="service_registration")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def register_registration_tools(mcp: Any) -> None:
|
|
885
|
+
"""Register registration tools with the MCP server.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
mcp: The FastMCP server instance.
|
|
889
|
+
"""
|
|
890
|
+
# Read operations
|
|
891
|
+
mcp.tool()(registration_list)
|
|
892
|
+
mcp.tool()(registration_get)
|
|
893
|
+
# Write operations (audit-before-write pattern)
|
|
894
|
+
mcp.tool()(registration_create)
|
|
895
|
+
mcp.tool()(registration_delete)
|
|
896
|
+
mcp.tool()(registration_activate)
|
|
897
|
+
mcp.tool()(serviceregistration_link)
|