mcp-cloudnex 1.0.0__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.
app/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CloudNex MCP Server package."""
2
+
3
+ __version__ = "1.0.0"
app/client/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """HTTP clients for CloudNex API and Hub."""
2
+
3
+ from app.client.backend_client import BackendClient, HubClient
4
+
5
+ __all__ = ["BackendClient", "HubClient"]
@@ -0,0 +1,479 @@
1
+ """
2
+ HTTP clients for CloudNex Backend API and CloudNex Hub.
3
+
4
+ Authentication: Tenant API Key (Authorization: Api-Key {prefix.key})
5
+ - Uses Authorization: Api-Key header per CloudNex JSON:API backend
6
+ - API key is tenant-scoped; RBAC filters data by role and provider group assignments
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from typing import Any, Dict, Optional
14
+ from urllib.parse import urljoin
15
+
16
+ import httpx
17
+
18
+ from app.client.jsonapi import build_query_params
19
+ from app.core.config import settings
20
+ from app.core.exceptions import (
21
+ AccessForbiddenError,
22
+ AuthenticationError,
23
+ BackendConnectionError,
24
+ MCPServerError,
25
+ ResourceNotFoundError,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def mask_api_key(api_key: str) -> str:
32
+ """Mask API key for logging — show prefix only (before first dot)."""
33
+ if not api_key:
34
+ return "***"
35
+ if "." in api_key:
36
+ return f"{api_key.split('.', 1)[0]}.***"
37
+ if len(api_key) > 8:
38
+ return f"{api_key[:8]}***"
39
+ return "***"
40
+
41
+
42
+ class BackendClient:
43
+ """HTTP client for CloudNex JSON:API backend."""
44
+
45
+ def __init__(self, api_key: Optional[str] = None):
46
+ self.base_url = (settings.cloudnex_api_url or "").strip().rstrip("/")
47
+ self.api_key = (api_key if api_key is not None else settings.cloudnex_api_key or "").strip()
48
+ self.timeout = settings.cloudnex_backend_timeout
49
+ self.retry_attempts = settings.cloudnex_backend_retry_attempts
50
+ self.retry_delay = settings.cloudnex_backend_retry_delay
51
+
52
+ if self.api_key:
53
+ logger.info("BackendClient initialized with API key: %s", mask_api_key(self.api_key))
54
+ else:
55
+ logger.warning("BackendClient: CLOUDNEX_API_KEY not set.")
56
+
57
+ if self.base_url:
58
+ logger.info("BackendClient base URL: %s", self.base_url[:80])
59
+ else:
60
+ logger.warning("BackendClient: CLOUDNEX_API_URL not set.")
61
+
62
+ def _require_backend_config(self) -> None:
63
+ if not self.base_url:
64
+ raise MCPServerError(
65
+ "CLOUDNEX_API_URL is not set. Set this environment variable to your "
66
+ "CloudNex API base URL (e.g. https://api.example.com/api/v1)."
67
+ )
68
+ if not self.api_key:
69
+ raise MCPServerError(
70
+ "CLOUDNEX_API_KEY is not set. Set this environment variable to a "
71
+ "tenant API key (format: prefix.key)."
72
+ )
73
+
74
+ def _build_headers(self, api_key_override: Optional[str] = None) -> Dict[str, str]:
75
+ key = (api_key_override or self.api_key).strip()
76
+ return {
77
+ "Authorization": f"Api-Key {key}",
78
+ "Accept": "application/vnd.api+json",
79
+ "Content-Type": "application/vnd.api+json",
80
+ "User-Agent": f"{settings.cloudnex_mcp_server_name}/{settings.cloudnex_mcp_server_version}",
81
+ }
82
+
83
+ async def _make_request(
84
+ self,
85
+ method: str,
86
+ endpoint: str,
87
+ *,
88
+ params: Optional[Dict[str, str]] = None,
89
+ api_key: Optional[str] = None,
90
+ ) -> Dict[str, Any]:
91
+ """Make HTTP request with retry on timeout/connect errors only."""
92
+ self._require_backend_config()
93
+ url = urljoin(f"{self.base_url}/", endpoint.lstrip("/"))
94
+ headers = self._build_headers(api_key)
95
+
96
+ logger.debug("Request %s %s params=%s", method, url, params)
97
+
98
+ last_error: Optional[Exception] = None
99
+ for attempt in range(self.retry_attempts + 1):
100
+ try:
101
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
102
+ response = await client.request(method, url, headers=headers, params=params)
103
+
104
+ if response.status_code == 401:
105
+ raise AuthenticationError("Invalid API key")
106
+ if response.status_code == 403:
107
+ raise AccessForbiddenError(response.text or "Access denied (forbidden)")
108
+ if response.status_code == 404:
109
+ raise ResourceNotFoundError(response.text or "Resource not found")
110
+ if response.status_code >= 400:
111
+ raise BackendConnectionError(f"HTTP {response.status_code}: {response.text}")
112
+
113
+ if response.status_code == 204 or not response.content:
114
+ return {}
115
+ return response.json()
116
+
117
+ except httpx.TimeoutException as exc:
118
+ last_error = exc
119
+ if attempt < self.retry_attempts:
120
+ logger.warning("Request timeout, retrying in %ss", self.retry_delay)
121
+ await asyncio.sleep(self.retry_delay)
122
+ continue
123
+ raise BackendConnectionError("Request timeout after retries") from exc
124
+
125
+ except httpx.ConnectError as exc:
126
+ last_error = exc
127
+ if attempt < self.retry_attempts:
128
+ logger.warning("Connection failed, retrying in %ss", self.retry_delay)
129
+ await asyncio.sleep(self.retry_delay)
130
+ continue
131
+ raise BackendConnectionError("Failed to connect to CloudNex API") from exc
132
+
133
+ except (AuthenticationError, AccessForbiddenError, ResourceNotFoundError):
134
+ raise
135
+
136
+ except MCPServerError:
137
+ raise
138
+
139
+ except Exception as exc:
140
+ last_error = exc
141
+ if attempt < self.retry_attempts:
142
+ logger.warning("Request failed: %s, retrying", exc)
143
+ await asyncio.sleep(self.retry_delay)
144
+ continue
145
+ raise MCPServerError(f"Backend request failed: {exc}") from exc
146
+
147
+ raise MCPServerError(f"Backend request failed: {last_error}")
148
+
149
+ async def get_scans(
150
+ self,
151
+ *,
152
+ page: Optional[int] = None,
153
+ page_size: Optional[int] = None,
154
+ sort: Optional[str] = None,
155
+ filters: Optional[Dict[str, Any]] = None,
156
+ include: Optional[str] = None,
157
+ api_key: Optional[str] = None,
158
+ ) -> Dict[str, Any]:
159
+ params = build_query_params(
160
+ page=page,
161
+ page_size=page_size,
162
+ sort=sort,
163
+ filters=filters,
164
+ include=include,
165
+ allowed_filters={
166
+ "state", "provider_id", "search",
167
+ "filter[state]", "filter[provider_id]", "filter[search]",
168
+ },
169
+ )
170
+ return await self._make_request("GET", "/scans", params=params, api_key=api_key)
171
+
172
+ async def get_scan(self, scan_id: str, *, include: Optional[str] = None, api_key: Optional[str] = None) -> Dict[str, Any]:
173
+ params = build_query_params(include=include) if include else None
174
+ return await self._make_request("GET", f"/scans/{scan_id}", params=params, api_key=api_key)
175
+
176
+ async def get_latest_scan_id(self, *, api_key: Optional[str] = None) -> Optional[str]:
177
+ """Return the most recent scan ID for the tenant, if any."""
178
+ data = await self.get_scans(page_size=1, sort="-inserted_at", api_key=api_key)
179
+ resources = data.get("data")
180
+ if isinstance(resources, list) and resources:
181
+ return str(resources[0]["id"])
182
+ if isinstance(resources, dict):
183
+ return str(resources["id"])
184
+ return None
185
+
186
+ async def get_findings(
187
+ self,
188
+ *,
189
+ page: Optional[int] = None,
190
+ page_size: Optional[int] = None,
191
+ sort: Optional[str] = None,
192
+ filters: Optional[Dict[str, Any]] = None,
193
+ include: Optional[str] = "resources,scan.provider",
194
+ api_key: Optional[str] = None,
195
+ ) -> Dict[str, Any]:
196
+ params = build_query_params(
197
+ page=page,
198
+ page_size=page_size,
199
+ sort=sort,
200
+ filters=filters,
201
+ include=include,
202
+ allowed_filters={
203
+ "status", "severity", "provider_type", "service", "region", "search",
204
+ "scan", "scan_id",
205
+ "inserted_at", "inserted_at__gte", "inserted_at__lte",
206
+ "status__in", "severity__in", "provider_type__in", "service__in",
207
+ "filter[status]", "filter[severity]", "filter[provider_type]",
208
+ "filter[service]", "filter[region]", "filter[search]",
209
+ "filter[scan]", "filter[scan_id]",
210
+ "filter[inserted_at]", "filter[inserted_at__gte]", "filter[inserted_at__lte]",
211
+ "filter[status__in]", "filter[severity__in]", "filter[provider_type__in]",
212
+ },
213
+ )
214
+ return await self._make_request("GET", "/findings", params=params, api_key=api_key)
215
+
216
+ async def get_findings_metadata(
217
+ self,
218
+ *,
219
+ filters: Optional[Dict[str, Any]] = None,
220
+ api_key: Optional[str] = None,
221
+ ) -> Dict[str, Any]:
222
+ params = build_query_params(
223
+ filters=filters,
224
+ allowed_filters={
225
+ "scan", "scan_id",
226
+ "inserted_at", "inserted_at__gte", "inserted_at__lte",
227
+ "filter[scan]", "filter[scan_id]",
228
+ "filter[inserted_at]", "filter[inserted_at__gte]", "filter[inserted_at__lte]",
229
+ },
230
+ )
231
+ return await self._make_request("GET", "/findings/metadata", params=params or None, api_key=api_key)
232
+
233
+ async def get_latest_findings(
234
+ self,
235
+ *,
236
+ page: Optional[int] = None,
237
+ page_size: Optional[int] = None,
238
+ sort: Optional[str] = None,
239
+ filters: Optional[Dict[str, Any]] = None,
240
+ include: Optional[str] = "resources,scan.provider",
241
+ api_key: Optional[str] = None,
242
+ ) -> Dict[str, Any]:
243
+ params = build_query_params(
244
+ page=page,
245
+ page_size=page_size,
246
+ sort=sort,
247
+ filters=filters,
248
+ include=include,
249
+ allowed_filters={
250
+ "status", "severity", "provider_type", "service", "region", "search",
251
+ "filter[status]", "filter[severity]", "filter[provider_type]",
252
+ "filter[service]", "filter[region]", "filter[search]",
253
+ },
254
+ )
255
+ return await self._make_request("GET", "/findings/latest", params=params, api_key=api_key)
256
+
257
+ async def get_providers(
258
+ self,
259
+ *,
260
+ page: Optional[int] = None,
261
+ page_size: Optional[int] = None,
262
+ sort: Optional[str] = None,
263
+ filters: Optional[Dict[str, Any]] = None,
264
+ include: Optional[str] = None,
265
+ api_key: Optional[str] = None,
266
+ ) -> Dict[str, Any]:
267
+ params = build_query_params(
268
+ page=page,
269
+ page_size=page_size,
270
+ sort=sort,
271
+ filters=filters,
272
+ include=include or "provider_groups",
273
+ allowed_filters={"provider", "alias", "search", "filter[provider]", "filter[alias]", "filter[search]"},
274
+ )
275
+ return await self._make_request("GET", "/providers", params=params, api_key=api_key)
276
+
277
+ async def get_provider(
278
+ self,
279
+ provider_id: str,
280
+ *,
281
+ include: Optional[str] = None,
282
+ api_key: Optional[str] = None,
283
+ ) -> Dict[str, Any]:
284
+ params = build_query_params(include=include or "provider_groups")
285
+ return await self._make_request("GET", f"/providers/{provider_id}", params=params, api_key=api_key)
286
+
287
+ async def get_compliance_overviews(
288
+ self,
289
+ *,
290
+ page: Optional[int] = None,
291
+ page_size: Optional[int] = None,
292
+ sort: Optional[str] = None,
293
+ filters: Optional[Dict[str, Any]] = None,
294
+ api_key: Optional[str] = None,
295
+ ) -> Dict[str, Any]:
296
+ params = build_query_params(
297
+ page=page,
298
+ page_size=page_size,
299
+ sort=sort,
300
+ filters=filters,
301
+ allowed_filters={"scan_id", "filter[scan_id]"},
302
+ )
303
+ return await self._make_request("GET", "/compliance-overviews", params=params, api_key=api_key)
304
+
305
+ async def get_compliance_requirements(
306
+ self,
307
+ *,
308
+ page: Optional[int] = None,
309
+ page_size: Optional[int] = None,
310
+ sort: Optional[str] = None,
311
+ filters: Optional[Dict[str, Any]] = None,
312
+ api_key: Optional[str] = None,
313
+ ) -> Dict[str, Any]:
314
+ params = build_query_params(
315
+ page=page,
316
+ page_size=page_size,
317
+ sort=sort,
318
+ filters=filters,
319
+ allowed_filters={
320
+ "scan_id", "compliance_id", "filter[scan_id]", "filter[compliance_id]",
321
+ },
322
+ )
323
+ return await self._make_request("GET", "/compliance-overviews/requirements", params=params, api_key=api_key)
324
+
325
+ async def get_providers_overview(self, *, api_key: Optional[str] = None) -> Dict[str, Any]:
326
+ return await self._make_request("GET", "/overviews/providers", api_key=api_key)
327
+
328
+ async def get_findings_by_status(
329
+ self,
330
+ *,
331
+ filters: Optional[Dict[str, Any]] = None,
332
+ api_key: Optional[str] = None,
333
+ ) -> Dict[str, Any]:
334
+ params = build_query_params(
335
+ filters=filters,
336
+ allowed_filters={"provider_id", "filter[provider_id]"},
337
+ )
338
+ return await self._make_request("GET", "/overviews/findings", params=params or None, api_key=api_key)
339
+
340
+ async def get_findings_by_severity(
341
+ self,
342
+ *,
343
+ filters: Optional[Dict[str, Any]] = None,
344
+ api_key: Optional[str] = None,
345
+ ) -> Dict[str, Any]:
346
+ params = build_query_params(
347
+ filters=filters,
348
+ allowed_filters={"status", "provider_id", "filter[status]", "filter[provider_id]"},
349
+ )
350
+ return await self._make_request("GET", "/overviews/findings_severity", params=params or None, api_key=api_key)
351
+
352
+ async def get_users(
353
+ self,
354
+ *,
355
+ page: Optional[int] = None,
356
+ page_size: Optional[int] = None,
357
+ sort: Optional[str] = None,
358
+ filters: Optional[Dict[str, Any]] = None,
359
+ include: Optional[str] = None,
360
+ api_key: Optional[str] = None,
361
+ ) -> Dict[str, Any]:
362
+ params = build_query_params(
363
+ page=page,
364
+ page_size=page_size,
365
+ sort=sort,
366
+ filters=filters,
367
+ include=include,
368
+ allowed_filters={"search", "filter[search]"},
369
+ )
370
+ return await self._make_request("GET", "/users", params=params, api_key=api_key)
371
+
372
+ async def get_my_profile(self, *, include: Optional[str] = "roles", api_key: Optional[str] = None) -> Dict[str, Any]:
373
+ params = build_query_params(include=include) if include else None
374
+ return await self._make_request("GET", "/users/me", params=params, api_key=api_key)
375
+
376
+ async def get_roles(
377
+ self,
378
+ *,
379
+ page: Optional[int] = None,
380
+ page_size: Optional[int] = None,
381
+ sort: Optional[str] = None,
382
+ filters: Optional[Dict[str, Any]] = None,
383
+ api_key: Optional[str] = None,
384
+ ) -> Dict[str, Any]:
385
+ params = build_query_params(
386
+ page=page,
387
+ page_size=page_size,
388
+ sort=sort,
389
+ filters=filters,
390
+ allowed_filters={"name", "filter[name]"},
391
+ )
392
+ return await self._make_request("GET", "/roles", params=params, api_key=api_key)
393
+
394
+ async def get_role(self, role_id: str, *, api_key: Optional[str] = None) -> Dict[str, Any]:
395
+ return await self._make_request("GET", f"/roles/{role_id}", api_key=api_key)
396
+
397
+ async def get_resources(
398
+ self,
399
+ *,
400
+ page: Optional[int] = None,
401
+ page_size: Optional[int] = None,
402
+ sort: Optional[str] = None,
403
+ filters: Optional[Dict[str, Any]] = None,
404
+ include: Optional[str] = None,
405
+ api_key: Optional[str] = None,
406
+ ) -> Dict[str, Any]:
407
+ params = build_query_params(
408
+ page=page,
409
+ page_size=page_size,
410
+ sort=sort,
411
+ filters=filters,
412
+ include=include,
413
+ allowed_filters={
414
+ "provider_id", "service", "region", "search",
415
+ "scan", "scan_id",
416
+ "updated_at", "updated_at__gte", "updated_at__lte",
417
+ "filter[provider_id]", "filter[service]", "filter[region]", "filter[search]",
418
+ "filter[scan]", "filter[scan_id]",
419
+ "filter[updated_at]", "filter[updated_at__gte]", "filter[updated_at__lte]",
420
+ },
421
+ )
422
+ return await self._make_request("GET", "/resources", params=params, api_key=api_key)
423
+
424
+ async def get_resource(self, resource_id: str, *, include: Optional[str] = None, api_key: Optional[str] = None) -> Dict[str, Any]:
425
+ params = build_query_params(include=include) if include else None
426
+ return await self._make_request("GET", f"/resources/{resource_id}", params=params, api_key=api_key)
427
+
428
+ async def health_check(self, *, api_key: Optional[str] = None) -> bool:
429
+ """Lightweight health check via GET /users/me."""
430
+ try:
431
+ await self.get_my_profile(api_key=api_key)
432
+ return True
433
+ except Exception:
434
+ return False
435
+
436
+
437
+ class HubClient:
438
+ """Public CloudNex Hub client (no authentication required)."""
439
+
440
+ def __init__(self):
441
+ self.base_url = (settings.cloudnex_hub_url or "https://hub.cloudnex.com").strip().rstrip("/")
442
+ self.timeout = settings.cloudnex_backend_timeout
443
+
444
+ async def _make_request(
445
+ self,
446
+ method: str,
447
+ endpoint: str,
448
+ *,
449
+ params: Optional[Dict[str, str]] = None,
450
+ ) -> Dict[str, Any]:
451
+ url = urljoin(f"{self.base_url}/", endpoint.lstrip("/"))
452
+ headers = {
453
+ "Accept": "application/json",
454
+ "User-Agent": f"{settings.cloudnex_mcp_server_name}/{settings.cloudnex_mcp_server_version}",
455
+ }
456
+
457
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
458
+ try:
459
+ response = await client.request(method, url, headers=headers, params=params)
460
+ except httpx.RequestError as exc:
461
+ raise BackendConnectionError(
462
+ f"CloudNex Hub unreachable at {self.base_url}: {exc}. "
463
+ "Check network connectivity or set CLOUDNEX_HUB_URL."
464
+ ) from exc
465
+ if response.status_code >= 400:
466
+ raise BackendConnectionError(f"Hub HTTP {response.status_code}: {response.text}")
467
+ if not response.content:
468
+ return {}
469
+ return response.json()
470
+
471
+ async def get_compliance_frameworks(self, *, provider: Optional[str] = None) -> Dict[str, Any]:
472
+ params = {"provider": provider} if provider else None
473
+ return await self._make_request("GET", "/api/compliance-frameworks", params=params)
474
+
475
+ async def get_provider_checks(self, provider: str) -> Dict[str, Any]:
476
+ return await self._make_request("GET", f"/api/providers/{provider}/checks")
477
+
478
+ async def get_check_details(self, provider: str, check_id: str) -> Dict[str, Any]:
479
+ return await self._make_request("GET", f"/api/checks/{provider}/{check_id}")
app/client/jsonapi.py ADDED
@@ -0,0 +1,174 @@
1
+ """JSON:API query building and response formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Iterable, Optional
6
+
7
+ MAX_PAGE_SIZE = 100
8
+
9
+ SENSITIVE_FIELD_NAMES = frozenset(
10
+ {
11
+ "api_key",
12
+ "secret",
13
+ "password",
14
+ "token",
15
+ "credentials",
16
+ "private_key",
17
+ "access_key",
18
+ "secret_key",
19
+ "client_secret",
20
+ }
21
+ )
22
+
23
+
24
+ def clamp_page_size(page_size: Optional[int], default: int = 25) -> int:
25
+ """Clamp page size to JSON:API maximum."""
26
+ if page_size is None:
27
+ return min(default, MAX_PAGE_SIZE)
28
+ return max(1, min(int(page_size), MAX_PAGE_SIZE))
29
+
30
+
31
+ def build_query_params(
32
+ *,
33
+ page: Optional[int] = None,
34
+ page_size: Optional[int] = None,
35
+ sort: Optional[str] = None,
36
+ filters: Optional[Dict[str, Any]] = None,
37
+ include: Optional[str] = None,
38
+ allowed_filters: Optional[Iterable[str]] = None,
39
+ ) -> Dict[str, str]:
40
+ """Build JSON:API query parameters with whitelisted filters."""
41
+ params: Dict[str, str] = {}
42
+
43
+ if page is not None:
44
+ params["page[number]"] = str(max(1, int(page)))
45
+ if page_size is not None:
46
+ params["page[size]"] = str(clamp_page_size(page_size))
47
+ if sort:
48
+ params["sort"] = sort
49
+ if include:
50
+ params["include"] = include
51
+
52
+ allowed = set(allowed_filters or [])
53
+ for key, value in (filters or {}).items():
54
+ if value is None or value == "":
55
+ continue
56
+ filter_key = key if key.startswith("filter[") else f"filter[{key}]"
57
+ if allowed_filters is not None and filter_key not in allowed and key not in allowed:
58
+ continue
59
+ params[filter_key] = str(value)
60
+
61
+ return params
62
+
63
+
64
+ def _is_jsonapi_resource(data: Dict[str, Any]) -> bool:
65
+ return "type" in data and ("attributes" in data or "relationships" in data)
66
+
67
+
68
+ def redact_sensitive_fields(data: Any, *, _redact_keys: bool = False) -> Any:
69
+ """Recursively redact sensitive attribute keys from dict/list structures."""
70
+ if isinstance(data, dict):
71
+ if _is_jsonapi_resource(data):
72
+ redacted = dict(data)
73
+ if isinstance(data.get("attributes"), dict):
74
+ redacted["attributes"] = redact_sensitive_fields(
75
+ data["attributes"], _redact_keys=True
76
+ )
77
+ return redacted
78
+
79
+ redacted: Dict[str, Any] = {}
80
+ for key, value in data.items():
81
+ key_lower = key.lower()
82
+ if _redact_keys and any(sensitive in key_lower for sensitive in SENSITIVE_FIELD_NAMES):
83
+ redacted[key] = "***REDACTED***"
84
+ else:
85
+ redacted[key] = redact_sensitive_fields(value, _redact_keys=_redact_keys)
86
+ return redacted
87
+ if isinstance(data, list):
88
+ return [redact_sensitive_fields(item, _redact_keys=_redact_keys) for item in data]
89
+ return data
90
+
91
+
92
+ def _format_resource(resource: Dict[str, Any]) -> str:
93
+ """Format a single JSON:API resource for LLM consumption."""
94
+ lines = []
95
+ resource_type = resource.get("type", "unknown")
96
+ resource_id = resource.get("id", "")
97
+ lines.append(f"## {resource_type} ({resource_id})")
98
+
99
+ attributes = resource.get("attributes") or {}
100
+ for key, value in attributes.items():
101
+ if value is None:
102
+ continue
103
+ if isinstance(value, (dict, list)):
104
+ lines.append(f"- {key}: {value}")
105
+ else:
106
+ lines.append(f"- {key}: {value}")
107
+
108
+ relationships = resource.get("relationships") or {}
109
+ for rel_name, rel_data in relationships.items():
110
+ if not isinstance(rel_data, dict):
111
+ continue
112
+ rel_payload = rel_data.get("data")
113
+ if isinstance(rel_payload, list):
114
+ ids = [f"{item.get('type')}:{item.get('id')}" for item in rel_payload if item]
115
+ if ids:
116
+ lines.append(f"- related {rel_name}: {', '.join(ids)}")
117
+ elif isinstance(rel_payload, dict) and rel_payload:
118
+ lines.append(
119
+ f"- related {rel_name}: {rel_payload.get('type')}:{rel_payload.get('id')}"
120
+ )
121
+
122
+ return "\n".join(lines)
123
+
124
+
125
+ def format_jsonapi_for_ai(data: Any) -> str:
126
+ """Convert JSON:API response to readable text for LLM consumption."""
127
+ if not isinstance(data, dict):
128
+ return str(data)
129
+
130
+ data = redact_sensitive_fields(data)
131
+ lines: list[str] = []
132
+
133
+ if "meta" in data:
134
+ meta = data["meta"]
135
+ if isinstance(meta, dict):
136
+ pagination = meta.get("pagination") or meta
137
+ if isinstance(pagination, dict):
138
+ page = pagination.get("page") or pagination
139
+ if isinstance(page, dict):
140
+ lines.append(
141
+ "Pagination: "
142
+ f"page {page.get('number', '?')} of {page.get('pages', '?')} "
143
+ f"({page.get('count', '?')} total records)"
144
+ )
145
+
146
+ included_index: Dict[str, Dict[str, Any]] = {}
147
+ for item in data.get("included") or []:
148
+ if isinstance(item, dict) and "type" in item and "id" in item:
149
+ included_index[f"{item['type']}:{item['id']}"] = item
150
+
151
+ resources = data.get("data")
152
+ if isinstance(resources, dict):
153
+ lines.append(_format_resource(resources))
154
+ elif isinstance(resources, list):
155
+ if not resources:
156
+ lines.append("No records found.")
157
+ else:
158
+ lines.append(f"Found {len(resources)} record(s):\n")
159
+ for resource in resources:
160
+ if isinstance(resource, dict):
161
+ lines.append(_format_resource(resource))
162
+ lines.append("")
163
+ elif resources is None and not lines:
164
+ for key, value in data.items():
165
+ if key in ("meta", "links", "jsonapi"):
166
+ continue
167
+ lines.append(f"{key}: {value}")
168
+
169
+ if included_index:
170
+ lines.append("\nIncluded related resources:")
171
+ for key, resource in list(included_index.items())[:20]:
172
+ lines.append(_format_resource(resource))
173
+
174
+ return "\n".join(lines).strip() or "Empty response."