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.
@@ -0,0 +1,3 @@
1
+ """cloudrift-mcp: read-only MCP access to a CloudRift tenant's own data."""
2
+
3
+ __version__ = "0.1.0"
@@ -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"
@@ -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}")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cloudrift-mcp = cloudrift_mcp.server:main
@@ -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