@jadenrazo/cloudcost-mcp 0.1.8 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +12 -8
  2. package/dist/index.js +131 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,16 +1,20 @@
1
+ <h1 align="center">CloudCost MCP Server</h1>
2
+
1
3
  <p align="center">
2
- <h1 align="center">CloudCost MCP Server</h1>
3
- <p align="center">
4
- Multi-cloud cost analysis for Terraform powered by live pricing data from AWS, Azure, and GCP.
5
- <br />
6
- Built on the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> for seamless AI agent integration.
7
- </p>
4
+ Multi-cloud cost analysis for Terraform — powered by live pricing data from AWS, Azure, and GCP.
5
+ <br />
6
+ Built on the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> for seamless AI agent integration.
8
7
  </p>
9
8
 
10
9
  <p align="center">
10
+ <a href="https://github.com/jadenrazo/CloudCostMCP/actions/workflows/ci.yml"><img src="https://github.com/jadenrazo/CloudCostMCP/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
11
+ <a href="https://www.npmjs.com/package/@jadenrazo/cloudcost-mcp"><img src="https://img.shields.io/npm/v/@jadenrazo/cloudcost-mcp.svg" alt="npm version" /></a>
12
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
13
+ <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node.js" />
14
+ </p>
11
15
 
12
- ![CI](https://github.com/jadenrazo/CloudCostMCP/actions/workflows/ci.yml/badge.svg) [![npm version](https://img.shields.io/npm/v/@jadenrazo/cloudcost-mcp.svg)](https://www.npmjs.com/package/@jadenrazo/cloudcost-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
13
-
16
+ <p align="right">
17
+ <img src="https://github.com/user-attachments/assets/7d5f613a-851e-4480-900f-438d13f9a56e" alt="CloudCost MCP demo" width="700" />
14
18
  </p>
15
19
 
16
20
  <p align="center">
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./chunk-KZJSZMWM.js";
15
15
 
16
16
  // src/server.ts
17
+ import { createRequire } from "module";
17
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
20
 
@@ -55,8 +56,9 @@ function loadEnvConfig() {
55
56
  const config = {};
56
57
  const env = process.env;
57
58
  if (env.CLOUDCOST_CACHE_TTL || env.CLOUDCOST_CACHE_PATH) {
59
+ const parsedTtl = env.CLOUDCOST_CACHE_TTL ? parseInt(env.CLOUDCOST_CACHE_TTL, 10) : NaN;
58
60
  config.cache = {
59
- ttl_seconds: env.CLOUDCOST_CACHE_TTL ? parseInt(env.CLOUDCOST_CACHE_TTL, 10) : DEFAULT_CONFIG.cache.ttl_seconds,
61
+ ttl_seconds: !isNaN(parsedTtl) ? parsedTtl : DEFAULT_CONFIG.cache.ttl_seconds,
60
62
  db_path: env.CLOUDCOST_CACHE_PATH ?? ""
61
63
  };
62
64
  }
@@ -66,9 +68,10 @@ function loadEnvConfig() {
66
68
  };
67
69
  }
68
70
  if (env.CLOUDCOST_MONTHLY_HOURS) {
71
+ const parsedHours = parseInt(env.CLOUDCOST_MONTHLY_HOURS, 10);
69
72
  config.pricing = {
70
73
  ...DEFAULT_CONFIG.pricing,
71
- monthly_hours: parseInt(env.CLOUDCOST_MONTHLY_HOURS, 10)
74
+ monthly_hours: !isNaN(parsedHours) ? parsedHours : DEFAULT_CONFIG.pricing.monthly_hours
72
75
  };
73
76
  }
74
77
  return config;
@@ -157,18 +160,26 @@ var PricingCache = class {
157
160
  * table self-trims on read.
158
161
  */
159
162
  get(key) {
160
- const row = this.db.prepare(
161
- "SELECT * FROM pricing_cache WHERE key = ?"
162
- ).get(key);
163
- if (!row) return null;
164
- const now = (/* @__PURE__ */ new Date()).toISOString();
165
- if (row.expires_at <= now) {
166
- this.db.prepare("DELETE FROM pricing_cache WHERE key = ?").run(key);
167
- logger.debug("Cache miss (expired)", { key });
163
+ try {
164
+ const row = this.db.prepare(
165
+ "SELECT * FROM pricing_cache WHERE key = ?"
166
+ ).get(key);
167
+ if (!row) return null;
168
+ const now = (/* @__PURE__ */ new Date()).toISOString();
169
+ if (row.expires_at <= now) {
170
+ this.db.prepare("DELETE FROM pricing_cache WHERE key = ?").run(key);
171
+ logger.debug("Cache miss (expired)", { key });
172
+ return null;
173
+ }
174
+ logger.debug("Cache hit", { key });
175
+ return JSON.parse(row.data);
176
+ } catch (err) {
177
+ logger.warn("Cache get failed, treating as miss", {
178
+ key,
179
+ err: err instanceof Error ? err.message : String(err)
180
+ });
168
181
  return null;
169
182
  }
170
- logger.debug("Cache hit", { key });
171
- return JSON.parse(row.data);
172
183
  }
173
184
  /**
174
185
  * Store a value in the cache.
@@ -181,10 +192,11 @@ var PricingCache = class {
181
192
  * be reused for other cache domains in the future.
182
193
  */
183
194
  set(key, data, provider, service, region, ttlSeconds) {
184
- const now = /* @__PURE__ */ new Date();
185
- const expiresAt = new Date(now.getTime() + ttlSeconds * 1e3);
186
- this.db.prepare(
187
- `INSERT INTO pricing_cache (key, data, provider, service, region, created_at, expires_at)
195
+ try {
196
+ const now = /* @__PURE__ */ new Date();
197
+ const expiresAt = new Date(now.getTime() + ttlSeconds * 1e3);
198
+ this.db.prepare(
199
+ `INSERT INTO pricing_cache (key, data, provider, service, region, created_at, expires_at)
188
200
  VALUES (?, ?, ?, ?, ?, ?, ?)
189
201
  ON CONFLICT (key) DO UPDATE SET
190
202
  data = excluded.data,
@@ -193,58 +205,92 @@ var PricingCache = class {
193
205
  region = excluded.region,
194
206
  created_at = excluded.created_at,
195
207
  expires_at = excluded.expires_at`
196
- ).run(
197
- key,
198
- JSON.stringify(data),
199
- provider,
200
- service,
201
- region,
202
- now.toISOString(),
203
- expiresAt.toISOString()
204
- );
205
- logger.debug("Cache set", { key, ttlSeconds });
208
+ ).run(
209
+ key,
210
+ JSON.stringify(data),
211
+ provider,
212
+ service,
213
+ region,
214
+ now.toISOString(),
215
+ expiresAt.toISOString()
216
+ );
217
+ logger.debug("Cache set", { key, ttlSeconds });
218
+ } catch (err) {
219
+ logger.warn("Cache set failed, continuing without caching", {
220
+ key,
221
+ err: err instanceof Error ? err.message : String(err)
222
+ });
223
+ }
206
224
  }
207
225
  /** Remove a single cache entry. */
208
226
  invalidate(key) {
209
- const result = this.db.prepare("DELETE FROM pricing_cache WHERE key = ?").run(key);
210
- logger.debug("Cache invalidated", { key, removed: result.changes });
227
+ try {
228
+ const result = this.db.prepare("DELETE FROM pricing_cache WHERE key = ?").run(key);
229
+ logger.debug("Cache invalidated", { key, removed: result.changes });
230
+ } catch (err) {
231
+ logger.warn("Cache invalidate failed", {
232
+ key,
233
+ err: err instanceof Error ? err.message : String(err)
234
+ });
235
+ }
211
236
  }
212
237
  /** Remove every cache entry belonging to a provider. */
213
238
  invalidateByProvider(provider) {
214
- const result = this.db.prepare("DELETE FROM pricing_cache WHERE provider = ?").run(provider);
215
- logger.debug("Cache invalidated by provider", {
216
- provider,
217
- removed: result.changes
218
- });
239
+ try {
240
+ const result = this.db.prepare("DELETE FROM pricing_cache WHERE provider = ?").run(provider);
241
+ logger.debug("Cache invalidated by provider", {
242
+ provider,
243
+ removed: result.changes
244
+ });
245
+ } catch (err) {
246
+ logger.warn("Cache invalidateByProvider failed", {
247
+ provider,
248
+ err: err instanceof Error ? err.message : String(err)
249
+ });
250
+ }
219
251
  }
220
252
  /**
221
253
  * Delete all entries whose expiry timestamp is in the past.
222
254
  * Returns the number of rows removed.
223
255
  */
224
256
  cleanup() {
225
- const now = (/* @__PURE__ */ new Date()).toISOString();
226
- const result = this.db.prepare("DELETE FROM pricing_cache WHERE expires_at <= ?").run(now);
227
- const removed = result.changes;
228
- logger.debug("Cache cleanup complete", { removed });
229
- return removed;
257
+ try {
258
+ const now = (/* @__PURE__ */ new Date()).toISOString();
259
+ const result = this.db.prepare("DELETE FROM pricing_cache WHERE expires_at <= ?").run(now);
260
+ const removed = result.changes;
261
+ logger.debug("Cache cleanup complete", { removed });
262
+ return removed;
263
+ } catch (err) {
264
+ logger.warn("Cache cleanup failed", {
265
+ err: err instanceof Error ? err.message : String(err)
266
+ });
267
+ return 0;
268
+ }
230
269
  }
231
270
  /** Return aggregate statistics about the current cache state. */
232
271
  getStats() {
233
- const now = (/* @__PURE__ */ new Date()).toISOString();
234
- const totals = this.db.prepare(
235
- `SELECT
272
+ try {
273
+ const now = (/* @__PURE__ */ new Date()).toISOString();
274
+ const totals = this.db.prepare(
275
+ `SELECT
236
276
  COUNT(*) AS total_entries,
237
277
  COALESCE(SUM(LENGTH(data)), 0) AS size_bytes
238
278
  FROM pricing_cache`
239
- ).get();
240
- const expired = this.db.prepare(
241
- "SELECT COUNT(*) AS expired_entries FROM pricing_cache WHERE expires_at <= ?"
242
- ).get(now);
243
- return {
244
- total_entries: totals.total_entries,
245
- expired_entries: expired.expired_entries,
246
- size_bytes: totals.size_bytes
247
- };
279
+ ).get();
280
+ const expired = this.db.prepare(
281
+ "SELECT COUNT(*) AS expired_entries FROM pricing_cache WHERE expires_at <= ?"
282
+ ).get(now);
283
+ return {
284
+ total_entries: totals.total_entries,
285
+ expired_entries: expired.expired_entries,
286
+ size_bytes: totals.size_bytes
287
+ };
288
+ } catch (err) {
289
+ logger.warn("Cache getStats failed", {
290
+ err: err instanceof Error ? err.message : String(err)
291
+ });
292
+ return { total_entries: 0, expired_entries: 0, size_bytes: 0 };
293
+ }
248
294
  }
249
295
  /** Close the underlying database connection. */
250
296
  close() {
@@ -920,7 +966,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
920
966
  extractEc2Price(bulk, instanceType, region, os) {
921
967
  const products = bulk?.products ?? {};
922
968
  const onDemand = bulk?.terms?.OnDemand ?? {};
923
- for (const [sku, product] of Object.entries(products)) {
969
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
970
+ for (const [sku, product] of sortedEntries) {
924
971
  const attrs = product?.attributes ?? {};
925
972
  if (attrs.instanceType === instanceType && (attrs.operatingSystem ?? "").toLowerCase() === os.toLowerCase() && attrs.tenancy === "Shared" && attrs.capacitystatus === "Used") {
926
973
  const priceTerms = onDemand[sku];
@@ -934,7 +981,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
934
981
  extractRdsPrice(bulk, instanceClass, region, engine) {
935
982
  const products = bulk?.products ?? {};
936
983
  const onDemand = bulk?.terms?.OnDemand ?? {};
937
- for (const [sku, product] of Object.entries(products)) {
984
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
985
+ for (const [sku, product] of sortedEntries) {
938
986
  const attrs = product?.attributes ?? {};
939
987
  if (attrs.instanceType === instanceClass && (attrs.databaseEngine ?? "").toLowerCase().includes(engine.toLowerCase()) && attrs.deploymentOption === "Single-AZ") {
940
988
  const priceTerms = onDemand[sku];
@@ -948,7 +996,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
948
996
  extractEbsPrice(bulk, volumeType, region) {
949
997
  const products = bulk?.products ?? {};
950
998
  const onDemand = bulk?.terms?.OnDemand ?? {};
951
- for (const [sku, product] of Object.entries(products)) {
999
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
1000
+ for (const [sku, product] of sortedEntries) {
952
1001
  const attrs = product?.attributes ?? {};
953
1002
  const apiName = (attrs.volumeApiName ?? "").toLowerCase();
954
1003
  if (product?.productFamily === "Storage" && (apiName === volumeType.toLowerCase() || (attrs.volumeType ?? "").toLowerCase() === volumeType.toLowerCase())) {
@@ -1124,7 +1173,7 @@ function normalizeAzureCompute(item) {
1124
1173
  service_name: item.serviceName ?? "Virtual Machines",
1125
1174
  product_name: item.productName ?? "",
1126
1175
  meter_name: item.meterName ?? "",
1127
- pricing_source: "azure-retail-api"
1176
+ pricing_source: "live"
1128
1177
  },
1129
1178
  effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
1130
1179
  };
@@ -1144,7 +1193,7 @@ function normalizeAzureDatabase(item) {
1144
1193
  service_name: item.serviceName ?? "",
1145
1194
  product_name: item.productName ?? "",
1146
1195
  meter_name: item.meterName ?? "",
1147
- pricing_source: "azure-retail-api"
1196
+ pricing_source: "live"
1148
1197
  },
1149
1198
  effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
1150
1199
  };
@@ -1164,7 +1213,7 @@ function normalizeAzureStorage(item) {
1164
1213
  service_name: item.serviceName ?? "",
1165
1214
  product_name: item.productName ?? "",
1166
1215
  meter_name: item.meterName ?? "",
1167
- pricing_source: "azure-retail-api"
1216
+ pricing_source: "live"
1168
1217
  },
1169
1218
  effective_date: item.effectiveStartDate ?? (/* @__PURE__ */ new Date()).toISOString()
1170
1219
  };
@@ -1348,6 +1397,7 @@ var AzureRetailClient = class {
1348
1397
  const exactFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and armSkuName eq '${vmName}'`;
1349
1398
  const exactItems = await this.queryPricing(exactFilter);
1350
1399
  if (exactItems.length > 0) {
1400
+ exactItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1351
1401
  const match = exactItems.find(
1352
1402
  (i) => (i.meterName ?? "").toLowerCase().includes("vcore")
1353
1403
  ) ?? exactItems[0];
@@ -1360,6 +1410,7 @@ var AzureRetailClient = class {
1360
1410
  const seriesFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and contains(armSkuName, '${seriesFamily}') and contains(armSkuName, '${parsed.series}')`;
1361
1411
  const seriesItems = await this.queryPricing(seriesFilter);
1362
1412
  if (seriesItems.length > 0) {
1413
+ seriesItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1363
1414
  const perVcore = seriesItems[0].retailPrice ?? 0;
1364
1415
  const totalPrice = perVcore * parsed.vcpus;
1365
1416
  const adjusted = { ...seriesItems[0], retailPrice: totalPrice };
@@ -1376,10 +1427,10 @@ var AzureRetailClient = class {
1376
1427
  /Standard_([A-Z])(\d+)([a-z]*)_v(\d+)/i
1377
1428
  );
1378
1429
  if (!match) return null;
1379
- const [, family, vcpuStr, variant, version] = match;
1430
+ const [, family, vcpuStr, variant, version2] = match;
1380
1431
  const vcpus = parseInt(vcpuStr, 10);
1381
1432
  if (isNaN(vcpus) || vcpus === 0) return null;
1382
- const series = `${family}${variant}v${version}`;
1433
+ const series = `${family}${variant}v${version2}`;
1383
1434
  return { series, vcpus };
1384
1435
  }
1385
1436
  async getStoragePrice(diskType, region) {
@@ -1391,6 +1442,7 @@ var AzureRetailClient = class {
1391
1442
  const filter = this.buildODataFilter("Storage", armRegion, diskType);
1392
1443
  const items = await this.queryPricing(filter);
1393
1444
  if (items.length > 0) {
1445
+ items.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1394
1446
  const result = normalizeAzureStorage(items[0]);
1395
1447
  this.cache.set(cacheKey, result, "azure", "storage", region, CACHE_TTL2);
1396
1448
  return result;
@@ -1501,16 +1553,19 @@ var AzureRetailClient = class {
1501
1553
  */
1502
1554
  pickVmItem(items, vmSize, os) {
1503
1555
  if (items.length === 0) return null;
1556
+ const sorted = [...items].sort(
1557
+ (a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? "")
1558
+ );
1504
1559
  const vmLower = vmSize.toLowerCase();
1505
1560
  const isWindows = os.toLowerCase().includes("windows");
1506
- for (const item of items) {
1561
+ for (const item of sorted) {
1507
1562
  const skuLower = (item.skuName ?? "").toLowerCase();
1508
1563
  const hasWindows = skuLower.includes("windows");
1509
1564
  if (skuLower.includes(vmLower) && (isWindows && hasWindows || !isWindows && !hasWindows)) {
1510
1565
  return item;
1511
1566
  }
1512
1567
  }
1513
- return items[0];
1568
+ return sorted[0];
1514
1569
  }
1515
1570
  // -------------------------------------------------------------------------
1516
1571
  // Internal helpers – size interpolation
@@ -1995,8 +2050,11 @@ var GcpProvider = class {
1995
2050
  getNatGatewayPrice(region) {
1996
2051
  return this.loader.getNatGatewayPrice(region);
1997
2052
  }
1998
- getKubernetesPrice(region) {
1999
- return this.loader.getKubernetesPrice(region);
2053
+ getKubernetesPrice(region, mode) {
2054
+ return this.loader.getKubernetesPrice(
2055
+ region,
2056
+ mode === "autopilot" ? "autopilot" : "standard"
2057
+ );
2000
2058
  }
2001
2059
  };
2002
2060
  var PricingEngine = class {
@@ -2056,7 +2114,7 @@ var PricingEngine = class {
2056
2114
  return p.getNatGatewayPrice(region);
2057
2115
  }
2058
2116
  if (svc === "k8s" || svc === "kubernetes" || svc === "eks" || svc === "aks" || svc === "gke") {
2059
- return p.getKubernetesPrice(region);
2117
+ return p.getKubernetesPrice(region, attributes.mode);
2060
2118
  }
2061
2119
  logger.warn("PricingEngine: unrecognised service", { service, provider });
2062
2120
  return null;
@@ -2979,7 +3037,7 @@ function findNearestInstance(spec, targetProvider) {
2979
3037
  if (candidate.category === spec.category) {
2980
3038
  score += SCORE_CATEGORY;
2981
3039
  }
2982
- if (score > bestScore) {
3040
+ if (score > bestScore || score === bestScore && bestType !== null && candidate.instance_type < bestType) {
2983
3041
  bestScore = score;
2984
3042
  bestType = candidate.instance_type;
2985
3043
  }
@@ -3716,7 +3774,9 @@ var CostEngine = class {
3716
3774
  total_monthly: Math.round(totalMonthly * 100) / 100,
3717
3775
  total_yearly: Math.round(totalMonthly * 12 * 100) / 100,
3718
3776
  currency: "USD",
3719
- by_service: byService,
3777
+ by_service: Object.fromEntries(
3778
+ Object.entries(byService).map(([k, v]) => [k, Math.round(v * 100) / 100])
3779
+ ),
3720
3780
  by_resource: estimates,
3721
3781
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3722
3782
  warnings
@@ -4363,7 +4423,7 @@ async function getEquivalents(params) {
4363
4423
  import { z as z5 } from "zod";
4364
4424
  var getPricingSchema = z5.object({
4365
4425
  provider: z5.enum(["aws", "azure", "gcp"]).describe("Cloud provider to look up pricing for"),
4366
- service: z5.enum(["compute", "database", "storage", "network", "kubernetes"]).describe("Service category"),
4426
+ service: z5.enum(["compute", "database", "storage", "network", "load_balancer", "nat_gateway", "kubernetes"]).describe("Service category (network defaults to nat_gateway for backward compatibility)"),
4367
4427
  resource_type: z5.string().describe(
4368
4428
  "Instance type, storage type, or resource identifier (e.g. t3.large, gp3, Standard_D4s_v3)"
4369
4429
  ),
@@ -4376,6 +4436,8 @@ async function getPricing(params, pricingEngine) {
4376
4436
  database: "database",
4377
4437
  storage: "storage",
4378
4438
  network: "nat-gateway",
4439
+ load_balancer: "load-balancer",
4440
+ nat_gateway: "nat-gateway",
4379
4441
  kubernetes: "kubernetes"
4380
4442
  };
4381
4443
  const service = serviceMap[params.service] ?? params.service;
@@ -4514,12 +4576,14 @@ function registerTools(server, config) {
4514
4576
  }
4515
4577
 
4516
4578
  // src/server.ts
4579
+ var require2 = createRequire(import.meta.url);
4580
+ var { version } = require2("../package.json");
4517
4581
  async function startServer() {
4518
4582
  const config = loadConfig();
4519
4583
  setLogLevel(config.logging.level);
4520
4584
  const server = new McpServer({
4521
4585
  name: "cloudcost-mcp",
4522
- version: "0.1.0"
4586
+ version
4523
4587
  });
4524
4588
  registerTools(server, config);
4525
4589
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jadenrazo/cloudcost-mcp",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for multi-cloud cost analysis of Terraform codebases",
5
5
  "type": "module",
6
6
  "license": "MIT",