mcp-eregistrations-bpa 0.8.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-eregistrations-bpa might be problematic. Click here for more details.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,352 @@
1
+ """Service analysis tools for BPA API.
2
+
3
+ This module provides MCP tools for analyzing BPA services.
4
+ Note: The BPA API is service-centric and does not support cross-object
5
+ relationship queries. Analysis is limited to service-level data.
6
+
7
+ Tools:
8
+ analyze_service: Analyze a BPA service with AI-optimized output
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp.exceptions import ToolError
16
+
17
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
18
+ from mcp_eregistrations_bpa.bpa_client.errors import (
19
+ BPAClientError,
20
+ BPANotFoundError,
21
+ translate_error,
22
+ )
23
+
24
+ __all__ = [
25
+ "analyze_service",
26
+ "register_analysis_tools",
27
+ ]
28
+
29
+
30
+ def _transform_summary(item: dict[str, Any]) -> dict[str, Any]:
31
+ """Transform an item to a summary with id/key and name."""
32
+ return {
33
+ "id": item.get("id"),
34
+ "key": item.get("key"),
35
+ "name": item.get("name"),
36
+ }
37
+
38
+
39
+ def _calculate_complexity_score(
40
+ registration_count: int,
41
+ field_count: int,
42
+ determinant_count: int,
43
+ ) -> str:
44
+ """Calculate service complexity score.
45
+
46
+ Scoring criteria:
47
+ - low: ≤5 registrations, ≤20 fields, ≤5 determinants
48
+ - medium: ≤15 registrations, ≤50 fields, ≤20 determinants
49
+ - high: Exceeds medium thresholds
50
+
51
+ Args:
52
+ registration_count: Number of registrations in the service.
53
+ field_count: Number of fields in the service.
54
+ determinant_count: Number of determinants in the service.
55
+
56
+ Returns:
57
+ Complexity level: "low", "medium", or "high".
58
+ """
59
+ if registration_count <= 5 and field_count <= 20 and determinant_count <= 5:
60
+ return "low"
61
+ elif registration_count <= 15 and field_count <= 50 and determinant_count <= 20:
62
+ return "medium"
63
+ else:
64
+ return "high"
65
+
66
+
67
+ def _build_service_overview(
68
+ service_data: dict[str, Any],
69
+ registrations: list[dict[str, Any]],
70
+ fields: list[dict[str, Any]],
71
+ determinants: list[dict[str, Any]],
72
+ forms: list[dict[str, Any]],
73
+ ) -> dict[str, Any]:
74
+ """Build service overview with counts for AI reasoning.
75
+
76
+ Args:
77
+ service_data: Service metadata from API.
78
+ registrations: List of registrations.
79
+ fields: List of fields.
80
+ determinants: List of determinants.
81
+ forms: List of forms.
82
+
83
+ Returns:
84
+ Overview dictionary with status, counts, and complexity score.
85
+ """
86
+ return {
87
+ "status": service_data.get("status", "active"),
88
+ "total_registrations": len(registrations),
89
+ "total_fields": len(fields),
90
+ "total_determinants": len(determinants),
91
+ "total_forms": len(forms),
92
+ "complexity_score": _calculate_complexity_score(
93
+ len(registrations), len(fields), len(determinants)
94
+ ),
95
+ }
96
+
97
+
98
+ def _build_fields_summary(
99
+ fields: list[dict[str, Any]],
100
+ max_sample_size: int = 10,
101
+ ) -> dict[str, Any]:
102
+ """Build a summarized fields object for AI consumption.
103
+
104
+ Instead of returning all fields (which can be 1000+ items), this function
105
+ returns a summary with:
106
+ - total: Count of all fields
107
+ - by_type: Breakdown of fields by type with counts
108
+ - sample: Representative sample of up to max_sample_size fields
109
+
110
+ Sample selection strategy:
111
+ - Distribute samples across different field types for variety
112
+ - If fewer fields than max_sample_size, return all fields
113
+ - Include key, type, name for each sample field
114
+
115
+ Args:
116
+ fields: List of field dictionaries from BPA API.
117
+ max_sample_size: Maximum number of fields in the sample (default: 10).
118
+
119
+ Returns:
120
+ Dictionary with total, by_type, and sample fields.
121
+ """
122
+ total = len(fields)
123
+
124
+ # Group fields by type
125
+ fields_by_type: dict[str, list[dict[str, Any]]] = {}
126
+ for field in fields:
127
+ field_type = field.get("type", "unknown")
128
+ if field_type not in fields_by_type:
129
+ fields_by_type[field_type] = []
130
+ fields_by_type[field_type].append(field)
131
+
132
+ # Build by_type counts
133
+ by_type = {field_type: len(items) for field_type, items in fields_by_type.items()}
134
+
135
+ # Build sample with diversity across types
136
+ sample: list[dict[str, Any]] = []
137
+ if total <= max_sample_size:
138
+ # Return all fields if we have fewer than max_sample_size
139
+ sample = [
140
+ {
141
+ "key": f.get("key"),
142
+ "type": f.get("type"),
143
+ "name": f.get("name"),
144
+ }
145
+ for f in fields
146
+ ]
147
+ else:
148
+ # Distribute samples across types for diversity
149
+ type_count = len(fields_by_type)
150
+ if type_count > 0:
151
+ # Calculate base samples per type
152
+ samples_per_type = max(1, max_sample_size // type_count)
153
+ remaining_slots = max_sample_size
154
+
155
+ # Sort types by count (largest first) to prioritize common types
156
+ sorted_types = sorted(
157
+ fields_by_type.keys(),
158
+ key=lambda t: len(fields_by_type[t]),
159
+ reverse=True,
160
+ )
161
+
162
+ # Track how many we took from each type
163
+ taken_from_type: dict[str, int] = {t: 0 for t in sorted_types}
164
+
165
+ # First pass: take samples_per_type from each type
166
+ for field_type in sorted_types:
167
+ if remaining_slots <= 0:
168
+ break
169
+ type_fields = fields_by_type[field_type]
170
+ take_count = min(samples_per_type, len(type_fields), remaining_slots)
171
+ for f in type_fields[:take_count]:
172
+ sample.append(
173
+ {
174
+ "key": f.get("key"),
175
+ "type": f.get("type"),
176
+ "name": f.get("name"),
177
+ }
178
+ )
179
+ taken_from_type[field_type] = take_count
180
+ remaining_slots -= take_count
181
+
182
+ # Second pass: fill remaining slots from types with more fields
183
+ for field_type in sorted_types:
184
+ if remaining_slots <= 0:
185
+ break
186
+ type_fields = fields_by_type[field_type]
187
+ already_taken = taken_from_type[field_type]
188
+ available = len(type_fields) - already_taken
189
+ if available > 0:
190
+ take_more = min(available, remaining_slots)
191
+ for f in type_fields[already_taken : already_taken + take_more]:
192
+ sample.append(
193
+ {
194
+ "key": f.get("key"),
195
+ "type": f.get("type"),
196
+ "name": f.get("name"),
197
+ }
198
+ )
199
+ remaining_slots -= take_more
200
+
201
+ return {
202
+ "total": total,
203
+ "by_type": by_type,
204
+ "sample": sample,
205
+ }
206
+
207
+
208
+ def _generate_insights(overview: dict[str, Any]) -> list[str]:
209
+ """Generate actionable insights for AI reasoning.
210
+
211
+ Args:
212
+ overview: Service overview data.
213
+
214
+ Returns:
215
+ List of insight strings.
216
+ """
217
+ insights: list[str] = []
218
+
219
+ # Complexity insight
220
+ complexity = overview.get("complexity_score", "low")
221
+ total_fields = overview.get("total_fields", 0)
222
+ total_determinants = overview.get("total_determinants", 0)
223
+ total_registrations = overview.get("total_registrations", 0)
224
+
225
+ if complexity == "high":
226
+ insights.append("Service has high complexity - consider modular restructuring")
227
+ elif complexity == "medium":
228
+ insights.append("Service has moderate complexity")
229
+ else:
230
+ insights.append("Service has low complexity - well organized")
231
+
232
+ # Field insights
233
+ if total_fields > 50:
234
+ insights.append(f"Service has {total_fields} fields - review for consolidation")
235
+
236
+ # Determinant insights
237
+ if total_determinants > 20:
238
+ insights.append(
239
+ f"Service has {total_determinants} determinants - "
240
+ "complex business rules present"
241
+ )
242
+ elif total_determinants == 0:
243
+ insights.append("No determinants - all fields are always visible")
244
+
245
+ # Registration insights
246
+ if total_registrations > 10:
247
+ insights.append(
248
+ f"Service has {total_registrations} registrations - "
249
+ "consider grouping related ones"
250
+ )
251
+ elif total_registrations == 0:
252
+ insights.append("No registrations defined yet")
253
+
254
+ return insights
255
+
256
+
257
+ async def analyze_service(service_id: str | int) -> dict[str, Any]:
258
+ """Analyze a BPA service with AI-optimized output.
259
+
260
+ Note: BPA API is service-centric. Cross-object relationships
261
+ (e.g., which fields use which determinants) are not queryable.
262
+
263
+ Args:
264
+ service_id: The unique identifier of the service.
265
+
266
+ Returns:
267
+ dict with service_id, service_name, overview, registrations,
268
+ fields_summary, determinants, insights.
269
+ """
270
+ try:
271
+ async with BPAClient() as client:
272
+ try:
273
+ # Get service details (includes embedded registrations)
274
+ service_data = await client.get(
275
+ "/service/{id}",
276
+ path_params={"id": service_id},
277
+ resource_type="service",
278
+ resource_id=service_id,
279
+ )
280
+
281
+ # Extract registrations from service response
282
+ # Note: BPA API embeds registrations in service, no separate endpoint
283
+ registrations = service_data.get("registrations", [])
284
+
285
+ # Get fields using service-scoped endpoint
286
+ fields = await client.get_list(
287
+ "/service/{service_id}/fields",
288
+ path_params={"service_id": service_id},
289
+ resource_type="field",
290
+ )
291
+
292
+ # Get determinants using service-scoped endpoint
293
+ determinants = await client.get_list(
294
+ "/service/{service_id}/determinant",
295
+ path_params={"service_id": service_id},
296
+ resource_type="determinant",
297
+ )
298
+
299
+ # Note: /service/{id}/form endpoint doesn't exist
300
+ # Forms are accessed via /service/{id}/applicant-form
301
+ forms: list[dict[str, Any]] = []
302
+
303
+ # Build AI-optimized response
304
+ overview = _build_service_overview(
305
+ service_data, registrations, fields, determinants, forms
306
+ )
307
+
308
+ insights = _generate_insights(overview)
309
+
310
+ # Build fields summary instead of returning all fields
311
+ # This reduces response size from ~132KB to ~5KB for large services
312
+ fields_summary = _build_fields_summary(fields)
313
+
314
+ return {
315
+ "service_id": service_id,
316
+ "service_name": service_data.get("name", ""),
317
+ "description": service_data.get("description", ""),
318
+ "overview": overview,
319
+ "registrations": [
320
+ {"id": r.get("id"), "name": r.get("name")}
321
+ for r in registrations
322
+ ],
323
+ "fields_summary": fields_summary,
324
+ "determinants": [
325
+ {
326
+ "id": d.get("id"),
327
+ "name": d.get("name"),
328
+ "type": d.get("type"),
329
+ }
330
+ for d in determinants
331
+ ],
332
+ "insights": insights,
333
+ }
334
+
335
+ except BPANotFoundError:
336
+ raise ToolError(
337
+ f"Service {service_id} not found. "
338
+ "Use 'service_list' to see available services."
339
+ )
340
+ except ToolError:
341
+ raise
342
+ except BPAClientError as e:
343
+ raise translate_error(e, resource_type="service", resource_id=service_id)
344
+
345
+
346
+ def register_analysis_tools(mcp: Any) -> None:
347
+ """Register analysis tools with the MCP server.
348
+
349
+ Args:
350
+ mcp: The FastMCP server instance.
351
+ """
352
+ mcp.tool()(analyze_service)