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