cloudrift-mcp 0.1.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.
- cloudrift_mcp/__init__.py +3 -0
- cloudrift_mcp/client.py +102 -0
- cloudrift_mcp/errors.py +19 -0
- cloudrift_mcp/server.py +299 -0
- cloudrift_mcp-0.1.0.dist-info/METADATA +127 -0
- cloudrift_mcp-0.1.0.dist-info/RECORD +10 -0
- cloudrift_mcp-0.1.0.dist-info/WHEEL +5 -0
- cloudrift_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- cloudrift_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- cloudrift_mcp-0.1.0.dist-info/top_level.txt +1 -0
cloudrift_mcp/client.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP access to the CloudRift public API (read-only GETs).
|
|
2
|
+
|
|
3
|
+
Configuration comes from the environment ONLY:
|
|
4
|
+
|
|
5
|
+
CLOUDRIFT_API_KEY required. A CloudRift API key (crk_live_...).
|
|
6
|
+
Without it the server still starts, but every
|
|
7
|
+
tool returns a clear CONFIG error.
|
|
8
|
+
CLOUDRIFT_API_URL optional. Defaults to https://cloudrift.tech.
|
|
9
|
+
|
|
10
|
+
The key is sent as `Authorization: Bearer <key>`. It is never accepted
|
|
11
|
+
as a tool argument and never echoed into any tool result or error
|
|
12
|
+
message. All requests are GETs with a 30 second timeout; failures are
|
|
13
|
+
returned as honest one-line tool errors (status + the API's own
|
|
14
|
+
detail), never stack traces.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .errors import API_ERROR, CONFIG, NETWORK_ERROR, tool_error
|
|
25
|
+
|
|
26
|
+
DEFAULT_API_URL = "https://cloudrift.tech"
|
|
27
|
+
TIMEOUT_SECONDS = 30.0
|
|
28
|
+
|
|
29
|
+
# Tests set this to an httpx.MockTransport; production leaves it None.
|
|
30
|
+
transport: Optional[httpx.BaseTransport] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def base_url() -> str:
|
|
34
|
+
"""The API origin, from CLOUDRIFT_API_URL, without a trailing slash."""
|
|
35
|
+
return (os.environ.get("CLOUDRIFT_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _api_key() -> str:
|
|
39
|
+
key = (os.environ.get("CLOUDRIFT_API_KEY") or "").strip()
|
|
40
|
+
if not key:
|
|
41
|
+
raise tool_error(
|
|
42
|
+
CONFIG,
|
|
43
|
+
"CLOUDRIFT_API_KEY is not set. Create a read-only API key in "
|
|
44
|
+
"CloudRift (Settings > API Keys) and export it in the "
|
|
45
|
+
"environment of this MCP server; keys are never accepted as "
|
|
46
|
+
"tool arguments.")
|
|
47
|
+
return key
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get(path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
51
|
+
"""GET an /api/v1 path and return the parsed JSON body.
|
|
52
|
+
|
|
53
|
+
`path` is the part after the origin, e.g. "/api/v1/resources".
|
|
54
|
+
Raises a ToolError (CONFIG / NETWORK_ERROR / API_ERROR) on any
|
|
55
|
+
failure; the message carries the status and the API's own `detail`
|
|
56
|
+
when there is one.
|
|
57
|
+
"""
|
|
58
|
+
url = base_url() + path
|
|
59
|
+
headers = {"Authorization": "Bearer " + _api_key()}
|
|
60
|
+
try:
|
|
61
|
+
with httpx.Client(timeout=TIMEOUT_SECONDS, transport=transport) as http:
|
|
62
|
+
response = http.get(url, params=params or {}, headers=headers)
|
|
63
|
+
except httpx.TimeoutException:
|
|
64
|
+
raise tool_error(
|
|
65
|
+
NETWORK_ERROR,
|
|
66
|
+
f"GET {path} timed out after {int(TIMEOUT_SECONDS)}s "
|
|
67
|
+
f"({base_url()}).") from None
|
|
68
|
+
except httpx.HTTPError as exc:
|
|
69
|
+
# One honest line (e.g. connection refused), never a traceback.
|
|
70
|
+
raise tool_error(
|
|
71
|
+
NETWORK_ERROR,
|
|
72
|
+
f"could not reach {base_url()}: {exc.__class__.__name__}: "
|
|
73
|
+
f"{exc}") from None
|
|
74
|
+
|
|
75
|
+
if response.status_code >= 400:
|
|
76
|
+
raise tool_error(
|
|
77
|
+
API_ERROR,
|
|
78
|
+
f"CloudRift API returned {response.status_code} for GET {path}: "
|
|
79
|
+
f"{_api_detail(response)}")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return response.json()
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise tool_error(
|
|
85
|
+
API_ERROR,
|
|
86
|
+
f"CloudRift API returned a non-JSON body for GET {path} "
|
|
87
|
+
f"(status {response.status_code}).") from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _api_detail(response: httpx.Response) -> str:
|
|
91
|
+
"""The API's own `detail` string, else a truncated body, else the
|
|
92
|
+
HTTP reason phrase."""
|
|
93
|
+
try:
|
|
94
|
+
body = response.json()
|
|
95
|
+
if isinstance(body, dict) and body.get("detail"):
|
|
96
|
+
return str(body["detail"])
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
text = (response.text or "").strip()
|
|
100
|
+
if text:
|
|
101
|
+
return text[:200]
|
|
102
|
+
return response.reason_phrase or "no detail provided"
|
cloudrift_mcp/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Structured tool errors.
|
|
2
|
+
|
|
3
|
+
Every failure a tool can hit is raised as a ToolError whose message
|
|
4
|
+
starts with a stable code, so an agent (or a human reading an editor
|
|
5
|
+
pane) gets "CODE: one clear sentence" and never a stack trace. The MCP
|
|
6
|
+
SDK converts a raised ToolError into an is_error tool result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
10
|
+
|
|
11
|
+
CONFIG = "CONFIG"
|
|
12
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
13
|
+
API_ERROR = "API_ERROR"
|
|
14
|
+
NETWORK_ERROR = "NETWORK_ERROR"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tool_error(code: str, message: str) -> ToolError:
|
|
18
|
+
"""Build (not raise) a ToolError with the standard CODE: message shape."""
|
|
19
|
+
return ToolError(f"{code}: {message}")
|
cloudrift_mcp/server.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""cloudrift-mcp stdio server.
|
|
2
|
+
|
|
3
|
+
Five read-only tools over the CloudRift public REST API (/api/v1), so
|
|
4
|
+
an AI assistant can query the customer's OWN cloud-waste findings and
|
|
5
|
+
verified-savings receipts. Every tool is a GET scoped by the API key's
|
|
6
|
+
tenant; nothing here can mutate, delete, or reach another tenant.
|
|
7
|
+
|
|
8
|
+
Credentials policy: no tool accepts a credential argument, ever. The
|
|
9
|
+
API key comes from the CLOUDRIFT_API_KEY environment variable only
|
|
10
|
+
(see client.py). Without it the server still starts and each tool
|
|
11
|
+
returns a clear CONFIG error.
|
|
12
|
+
|
|
13
|
+
Output policy: responses are trimmed to the fields an assistant needs.
|
|
14
|
+
Every list is capped, and the cap is stated in the result itself, so a
|
|
15
|
+
partial view is never silent.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from mcp.server.mcpserver import MCPServer
|
|
25
|
+
from mcp.types import ToolAnnotations
|
|
26
|
+
|
|
27
|
+
from . import __version__, client
|
|
28
|
+
from .errors import INVALID_ARGUMENT, tool_error
|
|
29
|
+
|
|
30
|
+
_READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
|
|
31
|
+
openWorldHint=False)
|
|
32
|
+
|
|
33
|
+
# The API serves at most 200 items per page; we walk at most this many
|
|
34
|
+
# pages per tool call so one call can never trigger an unbounded crawl.
|
|
35
|
+
PAGE_SIZE = 200
|
|
36
|
+
MAX_PAGES = 5
|
|
37
|
+
|
|
38
|
+
# Caps on what a tool returns inline. Stated in every capped result.
|
|
39
|
+
DEFAULT_FINDINGS_LIMIT = 25
|
|
40
|
+
MAX_FINDINGS_LIMIT = 100
|
|
41
|
+
RECOMMENDATIONS_CAP = 50
|
|
42
|
+
RECEIPT_ENTRIES_CAP = 50
|
|
43
|
+
BUDGETS_CAP = 50
|
|
44
|
+
ACCOUNTS_CAP = 50
|
|
45
|
+
|
|
46
|
+
mcp = MCPServer("cloudrift-mcp", version=__version__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fetch_pages(path: str, params: Optional[Dict[str, Any]] = None,
|
|
50
|
+
) -> Tuple[List[dict], int]:
|
|
51
|
+
"""Walk a paginated /api/v1 list endpoint.
|
|
52
|
+
|
|
53
|
+
Returns (items, total_reported_by_api). Fetches at most MAX_PAGES
|
|
54
|
+
pages of PAGE_SIZE, so items may be a bounded prefix of the total;
|
|
55
|
+
callers compare len(items) to the total and say so.
|
|
56
|
+
"""
|
|
57
|
+
items: List[dict] = []
|
|
58
|
+
total = 0
|
|
59
|
+
for page in range(1, MAX_PAGES + 1):
|
|
60
|
+
query = dict(params or {})
|
|
61
|
+
query.update({"page": page, "page_size": PAGE_SIZE})
|
|
62
|
+
body = client.get(path, query)
|
|
63
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
64
|
+
pagination = body.get("pagination", {}) if isinstance(body, dict) else {}
|
|
65
|
+
page_items = data if isinstance(data, list) else []
|
|
66
|
+
items.extend(d for d in page_items if isinstance(d, dict))
|
|
67
|
+
try:
|
|
68
|
+
total = int(pagination.get("total", len(items)))
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
total = len(items)
|
|
71
|
+
if len(page_items) < PAGE_SIZE or len(items) >= total:
|
|
72
|
+
break
|
|
73
|
+
return items, max(total, len(items))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _money(value: Any) -> float:
|
|
77
|
+
"""Non-negative float dollars; bad input counts as 0."""
|
|
78
|
+
try:
|
|
79
|
+
n = float(value)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return 0.0
|
|
82
|
+
if n != n or n in (float("inf"), float("-inf")) or n < 0:
|
|
83
|
+
return 0.0
|
|
84
|
+
return n
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_waste(resource: dict) -> bool:
|
|
88
|
+
"""A resource is a waste finding when the scanner flagged it as
|
|
89
|
+
orphaned, or recorded a non-empty optimization potential."""
|
|
90
|
+
if resource.get("is_orphaned"):
|
|
91
|
+
return True
|
|
92
|
+
potential = resource.get("optimization_potential")
|
|
93
|
+
if potential is None:
|
|
94
|
+
return False
|
|
95
|
+
return str(potential).strip().lower() not in ("", "none", "null", "0")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool(annotations=_READ_ONLY)
|
|
99
|
+
def list_waste_findings(account_id: Optional[str] = None,
|
|
100
|
+
limit: Optional[int] = None) -> Dict[str, Any]:
|
|
101
|
+
"""List the tenant's cloud-waste findings from its latest scans:
|
|
102
|
+
orphaned (unattached / unused) resources first, then resources with
|
|
103
|
+
optimization potential, each ordered by monthly cost descending.
|
|
104
|
+
|
|
105
|
+
account_id restricts to one cloud account (404 if it is not yours).
|
|
106
|
+
limit caps the findings returned (default 25, max 100); the result
|
|
107
|
+
states how many findings matched in total, and the summed monthly
|
|
108
|
+
cost of ALL matching findings, so totals never depend on the cap.
|
|
109
|
+
Read-only; reads /api/v1/resources with your CLOUDRIFT_API_KEY.
|
|
110
|
+
"""
|
|
111
|
+
if limit is None:
|
|
112
|
+
limit = DEFAULT_FINDINGS_LIMIT
|
|
113
|
+
if limit < 1 or limit > MAX_FINDINGS_LIMIT:
|
|
114
|
+
raise tool_error(
|
|
115
|
+
INVALID_ARGUMENT,
|
|
116
|
+
f"limit must be between 1 and {MAX_FINDINGS_LIMIT}.")
|
|
117
|
+
|
|
118
|
+
params: Dict[str, Any] = {}
|
|
119
|
+
if account_id:
|
|
120
|
+
params["account_id"] = account_id
|
|
121
|
+
resources, total_resources = _fetch_pages("/api/v1/resources", params)
|
|
122
|
+
|
|
123
|
+
findings = [r for r in resources if _is_waste(r)]
|
|
124
|
+
findings.sort(key=lambda r: (0 if r.get("is_orphaned") else 1,
|
|
125
|
+
-_money(r.get("monthly_cost"))))
|
|
126
|
+
shown = findings[:limit]
|
|
127
|
+
|
|
128
|
+
note = (f"Showing {len(shown)} of {len(findings)} waste findings, "
|
|
129
|
+
f"orphaned first, then monthly cost descending "
|
|
130
|
+
f"(limit={limit}, max {MAX_FINDINGS_LIMIT}).")
|
|
131
|
+
if len(resources) < total_resources:
|
|
132
|
+
note += (f" Scanned the first {len(resources)} of {total_resources} "
|
|
133
|
+
f"resources; pass account_id to narrow the scan.")
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"findings": [{
|
|
137
|
+
"id": r.get("id"),
|
|
138
|
+
"name": r.get("name"),
|
|
139
|
+
"type": r.get("type"),
|
|
140
|
+
"location": r.get("location"),
|
|
141
|
+
"resource_group": r.get("resource_group"),
|
|
142
|
+
"status": r.get("status"),
|
|
143
|
+
"monthly_cost": round(_money(r.get("monthly_cost")), 2),
|
|
144
|
+
"is_orphaned": bool(r.get("is_orphaned")),
|
|
145
|
+
"optimization_potential": r.get("optimization_potential"),
|
|
146
|
+
"account_id": r.get("account_id"),
|
|
147
|
+
} for r in shown],
|
|
148
|
+
"waste_findings_total": len(findings),
|
|
149
|
+
"orphaned_total": sum(1 for r in findings if r.get("is_orphaned")),
|
|
150
|
+
"monthly_waste_cost_total": round(
|
|
151
|
+
sum(_money(r.get("monthly_cost")) for r in findings), 2),
|
|
152
|
+
"resources_scanned": len(resources),
|
|
153
|
+
"note": note,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@mcp.tool(annotations=_READ_ONLY)
|
|
158
|
+
def get_recommendations(account_id: Optional[str] = None) -> Dict[str, Any]:
|
|
159
|
+
"""List the tenant's optimization recommendations (what to delete,
|
|
160
|
+
resize, or reconfigure and the estimated monthly saving for each),
|
|
161
|
+
ordered by estimated monthly savings descending.
|
|
162
|
+
|
|
163
|
+
account_id filters to one cloud account (applied locally; the API
|
|
164
|
+
returns all accounts). At most 50 recommendations are returned
|
|
165
|
+
inline; the result states the full matching count and the summed
|
|
166
|
+
estimated savings across ALL matches. Read-only; reads
|
|
167
|
+
/api/v1/recommendations with your CLOUDRIFT_API_KEY.
|
|
168
|
+
"""
|
|
169
|
+
recs, _total = _fetch_pages("/api/v1/recommendations")
|
|
170
|
+
if account_id:
|
|
171
|
+
recs = [r for r in recs if r.get("account_id") == account_id]
|
|
172
|
+
recs.sort(key=lambda r: -_money(r.get("estimated_monthly_savings")))
|
|
173
|
+
shown = recs[:RECOMMENDATIONS_CAP]
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"recommendations": [{
|
|
177
|
+
"resource_id": r.get("resource_id"),
|
|
178
|
+
"resource_name": r.get("resource_name"),
|
|
179
|
+
"resource_type": r.get("resource_type"),
|
|
180
|
+
"priority": r.get("priority"),
|
|
181
|
+
"category": r.get("category"),
|
|
182
|
+
"issue": r.get("issue"),
|
|
183
|
+
"action": r.get("action"),
|
|
184
|
+
"estimated_monthly_savings": round(
|
|
185
|
+
_money(r.get("estimated_monthly_savings")), 2),
|
|
186
|
+
"account_id": r.get("account_id"),
|
|
187
|
+
} for r in shown],
|
|
188
|
+
"recommendations_total": len(recs),
|
|
189
|
+
"estimated_monthly_savings_total": round(
|
|
190
|
+
sum(_money(r.get("estimated_monthly_savings")) for r in recs), 2),
|
|
191
|
+
"note": (f"Showing {len(shown)} of {len(recs)} recommendations, "
|
|
192
|
+
f"estimated monthly savings descending "
|
|
193
|
+
f"(cap {RECOMMENDATIONS_CAP})."),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@mcp.tool(annotations=_READ_ONLY)
|
|
198
|
+
def get_cost_summary() -> Dict[str, Any]:
|
|
199
|
+
"""Roll-up of the tenant's current monthly cloud spend and waste:
|
|
200
|
+
total monthly cost, orphaned (waste) monthly cost, annual savings
|
|
201
|
+
potential, resource counts, per-account breakdown, and the latest
|
|
202
|
+
scan date. These are the same canonical numbers the CloudRift
|
|
203
|
+
dashboard shows. Read-only; reads /api/v1/costs/summary with your
|
|
204
|
+
CLOUDRIFT_API_KEY.
|
|
205
|
+
"""
|
|
206
|
+
body = client.get("/api/v1/costs/summary")
|
|
207
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
data = {}
|
|
210
|
+
accounts = data.get("accounts") or []
|
|
211
|
+
shown_accounts = accounts[:ACCOUNTS_CAP]
|
|
212
|
+
|
|
213
|
+
result = {
|
|
214
|
+
"currency": data.get("currency", "USD"),
|
|
215
|
+
"total_monthly_cost": data.get("total_monthly_cost"),
|
|
216
|
+
"orphaned_monthly_cost": data.get("orphaned_monthly_cost"),
|
|
217
|
+
"annual_savings_potential": data.get("annual_savings_potential"),
|
|
218
|
+
"total_resources": data.get("total_resources"),
|
|
219
|
+
"orphaned_count": data.get("orphaned_count"),
|
|
220
|
+
"latest_scan_date": data.get("latest_scan_date"),
|
|
221
|
+
"accounts": shown_accounts,
|
|
222
|
+
}
|
|
223
|
+
if len(accounts) > len(shown_accounts):
|
|
224
|
+
result["note"] = (f"Showing {len(shown_accounts)} of {len(accounts)} "
|
|
225
|
+
f"accounts (cap {ACCOUNTS_CAP}); the totals above "
|
|
226
|
+
f"cover all accounts.")
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@mcp.tool(annotations=_READ_ONLY)
|
|
231
|
+
def get_savings_receipts() -> Dict[str, Any]:
|
|
232
|
+
"""The tenant's verified-savings ledger: receipts for savings
|
|
233
|
+
CloudRift actually executed, each labelled by verification status
|
|
234
|
+
(estimated / confirmed / billing_verified), with per-status totals
|
|
235
|
+
and the returned-to-date figure. The `basis` string from the API is
|
|
236
|
+
passed through verbatim - it states exactly how returned_to_date is
|
|
237
|
+
computed, so the number is always labelled as the extrapolation it
|
|
238
|
+
is. Requires CloudRift >= 2026-09-30. Read-only; reads
|
|
239
|
+
/api/v1/receipts with your CLOUDRIFT_API_KEY.
|
|
240
|
+
"""
|
|
241
|
+
body = client.get("/api/v1/receipts",
|
|
242
|
+
{"page": 1, "page_size": RECEIPT_ENTRIES_CAP})
|
|
243
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
244
|
+
if not isinstance(data, dict):
|
|
245
|
+
data = {}
|
|
246
|
+
entries = data.get("entries") or []
|
|
247
|
+
shown = entries[:RECEIPT_ENTRIES_CAP]
|
|
248
|
+
pagination = body.get("pagination", {}) if isinstance(body, dict) else {}
|
|
249
|
+
try:
|
|
250
|
+
total = int(pagination.get("total", len(entries)))
|
|
251
|
+
except (TypeError, ValueError):
|
|
252
|
+
total = len(entries)
|
|
253
|
+
total = max(total, len(entries))
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"totals": data.get("totals") or {},
|
|
257
|
+
"basis": data.get("basis"),
|
|
258
|
+
"entries": shown,
|
|
259
|
+
"entries_total": total,
|
|
260
|
+
"note": (f"Showing {len(shown)} of {total} ledger entries, newest "
|
|
261
|
+
f"first (cap {RECEIPT_ENTRIES_CAP}); totals and basis cover "
|
|
262
|
+
f"the full ledger and are reproduced verbatim from the "
|
|
263
|
+
f"API."),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@mcp.tool(annotations=_READ_ONLY)
|
|
268
|
+
def get_budgets() -> Dict[str, Any]:
|
|
269
|
+
"""The tenant's configured spend budgets: per-account monthly
|
|
270
|
+
limits with warning / critical thresholds and whether each budget
|
|
271
|
+
is enabled. Read-only; reads /api/v1/budgets with your
|
|
272
|
+
CLOUDRIFT_API_KEY.
|
|
273
|
+
"""
|
|
274
|
+
body = client.get("/api/v1/budgets")
|
|
275
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
276
|
+
budgets = data if isinstance(data, list) else []
|
|
277
|
+
shown = budgets[:BUDGETS_CAP]
|
|
278
|
+
|
|
279
|
+
result: Dict[str, Any] = {
|
|
280
|
+
"budgets": shown,
|
|
281
|
+
"budgets_total": len(budgets),
|
|
282
|
+
}
|
|
283
|
+
if len(budgets) > len(shown):
|
|
284
|
+
result["note"] = (f"Showing {len(shown)} of {len(budgets)} budgets "
|
|
285
|
+
f"(cap {BUDGETS_CAP}).")
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> None:
|
|
290
|
+
"""Entry point for `uvx cloudrift-mcp`."""
|
|
291
|
+
if not (os.environ.get("CLOUDRIFT_API_KEY") or "").strip():
|
|
292
|
+
print("cloudrift-mcp: CLOUDRIFT_API_KEY is not set; the server will "
|
|
293
|
+
"run, but every tool will return a CONFIG error until you "
|
|
294
|
+
"export a CloudRift API key", file=sys.stderr)
|
|
295
|
+
mcp.run("stdio")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cloudrift-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only MCP server exposing CloudRift cloud-waste findings and verified-savings receipts to AI assistants
|
|
5
|
+
Author-email: CloudRift <support@cloudrift.tech>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cloudrift.tech
|
|
8
|
+
Keywords: mcp,cloudrift,finops,cloud-cost,waste,savings
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: mcp<3,>=2
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# cloudrift-mcp
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server that gives your AI
|
|
29
|
+
assistant (Claude Code, Claude Desktop, Cursor, and any other MCP
|
|
30
|
+
client) read-only access to your own
|
|
31
|
+
[CloudRift](https://cloudrift.tech) data: cloud-waste findings,
|
|
32
|
+
optimization recommendations, cost summaries, budgets, and the
|
|
33
|
+
verified-savings ledger. The thesis is simple: in the agent era, your
|
|
34
|
+
cost data should be queryable by the assistants you already work in -
|
|
35
|
+
"what are my ten most expensive orphaned resources?" or "how much has
|
|
36
|
+
CloudRift actually saved us, and how is that number computed?" should
|
|
37
|
+
be one question away, answered from your tenant's live findings and
|
|
38
|
+
receipts rather than a stale export.
|
|
39
|
+
|
|
40
|
+
Read-only by construction. Every tool is a GET against the CloudRift
|
|
41
|
+
public API, scoped to the tenant that owns the API key. No tool
|
|
42
|
+
mutates anything, and no tool accepts a credential argument - the key
|
|
43
|
+
comes from the environment only.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/), or pip:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
uvx cloudrift-mcp # no install step
|
|
51
|
+
pip install cloudrift-mcp && cloudrift-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Create an API key in CloudRift (Settings > API Keys) with read-only
|
|
55
|
+
scopes, then export it as `CLOUDRIFT_API_KEY`.
|
|
56
|
+
|
|
57
|
+
### Claude Code
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
claude mcp add cloudrift --env CLOUDRIFT_API_KEY=crk_live_... -- uvx cloudrift-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Claude Desktop
|
|
64
|
+
|
|
65
|
+
Add to `claude_desktop_config.json` (Settings > Developer > Edit
|
|
66
|
+
Config):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"cloudrift": {
|
|
72
|
+
"command": "uvx",
|
|
73
|
+
"args": ["cloudrift-mcp"],
|
|
74
|
+
"env": {
|
|
75
|
+
"CLOUDRIFT_API_KEY": "crk_live_..."
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Environment variables
|
|
83
|
+
|
|
84
|
+
| Variable | Required | Meaning |
|
|
85
|
+
| ------------------- | -------- | -------------------------------------------------- |
|
|
86
|
+
| `CLOUDRIFT_API_KEY` | yes | A CloudRift API key. Without it the server starts, but every tool returns a clear CONFIG error. |
|
|
87
|
+
| `CLOUDRIFT_API_URL` | no | API origin; defaults to `https://cloudrift.tech`. |
|
|
88
|
+
|
|
89
|
+
## Tools
|
|
90
|
+
|
|
91
|
+
All tools are read-only GETs with a 30 second timeout. Lists are
|
|
92
|
+
capped, and every capped result states the cap and the full totals, so
|
|
93
|
+
a partial view is never silent.
|
|
94
|
+
|
|
95
|
+
| Tool | Arguments | Endpoint | Returns |
|
|
96
|
+
| --------------------- | --------------------------- | ------------------------- | ------- |
|
|
97
|
+
| `list_waste_findings` | `account_id?`, `limit?` (default 25, max 100) | `/api/v1/resources` | Waste findings: orphaned resources first, then optimization candidates, monthly cost descending, with full waste totals. |
|
|
98
|
+
| `get_recommendations` | `account_id?` (filtered locally) | `/api/v1/recommendations` | Optimization recommendations, estimated monthly savings descending (top 50), with the summed savings across all matches. |
|
|
99
|
+
| `get_cost_summary` | - | `/api/v1/costs/summary` | Monthly spend, orphaned (waste) cost, annual savings potential, per-account breakdown, latest scan date. |
|
|
100
|
+
| `get_savings_receipts`| - | `/api/v1/receipts` | The verified-savings ledger: entries labelled estimated / confirmed / billing_verified, per-status totals, and the API's `basis` string verbatim (how returned_to_date is computed). Requires CloudRift >= 2026-09-30. |
|
|
101
|
+
| `get_budgets` | - | `/api/v1/budgets` | Configured spend budgets with warning / critical thresholds. |
|
|
102
|
+
|
|
103
|
+
Errors come back as one honest line - `API_ERROR: CloudRift API
|
|
104
|
+
returned 403 for GET /api/v1/costs/summary: This API key is missing
|
|
105
|
+
the required scope: read:costs.` - never a stack trace.
|
|
106
|
+
|
|
107
|
+
## Security
|
|
108
|
+
|
|
109
|
+
- **Use a read-only key.** Grant only the `read:*` scopes
|
|
110
|
+
(`read:resources`, `read:recommendations`, `read:costs`,
|
|
111
|
+
`read:budgets`) when you mint the key. This server only ever issues
|
|
112
|
+
GETs, so a broader key buys nothing and risks more.
|
|
113
|
+
- **Env-only auth.** The key is read from `CLOUDRIFT_API_KEY` and sent
|
|
114
|
+
as `Authorization: Bearer`. No tool accepts a key as an argument (a
|
|
115
|
+
test enforces that no credential-shaped field exists in any tool
|
|
116
|
+
schema), and the key is never echoed into tool results or error
|
|
117
|
+
messages.
|
|
118
|
+
- **Tenant-scoped by the API.** The CloudRift public API scopes every
|
|
119
|
+
query to the key owner's account; this server adds no cross-tenant
|
|
120
|
+
reach.
|
|
121
|
+
- **Bounded requests.** At most five pages of 200 items are fetched
|
|
122
|
+
per tool call, and CloudRift rate-limits each key to 120
|
|
123
|
+
requests/minute.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cloudrift_mcp/__init__.py,sha256=SMYnCQy6QjgSAwDN_EiBnNz8c1liz_2wFE2ua665qMI,99
|
|
2
|
+
cloudrift_mcp/client.py,sha256=Bxke3QEnVSQpRZ3UdzllrD_whD1fd1lzzJJsx0KK_zk,3613
|
|
3
|
+
cloudrift_mcp/errors.py,sha256=6ccLC8rycbHAjkDMwEvx1J-DgcNBDHwtD_KFW3wiRP8,648
|
|
4
|
+
cloudrift_mcp/server.py,sha256=RTI3pnkK4JRk9k-LhyyUlg_Ct6RWHs_94zt2dHDNrEw,11993
|
|
5
|
+
cloudrift_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=L_qSw9VP9ScLYsbHoWyY6j6FNRGHMwTjybu3jgLVGxI,1066
|
|
6
|
+
cloudrift_mcp-0.1.0.dist-info/METADATA,sha256=5IQxQeccJ6FR8dgebXs7TBCS7_KIMMeSZoM_jmXKzKI,5504
|
|
7
|
+
cloudrift_mcp-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
cloudrift_mcp-0.1.0.dist-info/entry_points.txt,sha256=y6451uf64-Fz5QGT4LZy4DeuX9TZTaInwM81v-b8FTA,60
|
|
9
|
+
cloudrift_mcp-0.1.0.dist-info/top_level.txt,sha256=srVTyO2v6dpUU_oced1X9VHkHljxDDdDpKrB6-X38ZU,14
|
|
10
|
+
cloudrift_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CloudRift
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cloudrift_mcp
|