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 +3 -0
- app/client/__init__.py +5 -0
- app/client/backend_client.py +479 -0
- app/client/jsonapi.py +174 -0
- app/client/query_helpers.py +75 -0
- app/core/__init__.py +1 -0
- app/core/config.py +31 -0
- app/core/exceptions.py +29 -0
- app/http_server.py +224 -0
- app/mcp_server.py +157 -0
- app/resources/__init__.py +6 -0
- app/resources/base.py +52 -0
- app/resources/finding_resources.py +40 -0
- app/resources/scan_resources.py +29 -0
- app/tools/__init__.py +35 -0
- app/tools/base.py +122 -0
- app/tools/check_tools.py +109 -0
- app/tools/compliance_tools.py +122 -0
- app/tools/finding_tools.py +139 -0
- app/tools/overview_tools.py +105 -0
- app/tools/provider_tools.py +74 -0
- app/tools/resource_tools.py +105 -0
- app/tools/role_tools.py +72 -0
- app/tools/scan_tools.py +79 -0
- app/tools/user_tools.py +66 -0
- mcp_cloudnex-1.0.0.dist-info/METADATA +157 -0
- mcp_cloudnex-1.0.0.dist-info/RECORD +30 -0
- mcp_cloudnex-1.0.0.dist-info/WHEEL +5 -0
- mcp_cloudnex-1.0.0.dist-info/entry_points.txt +2 -0
- mcp_cloudnex-1.0.0.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
app/client/__init__.py
ADDED
|
@@ -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."
|