@jadenrazo/cloudcost-mcp 0.1.7 → 0.1.9

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 +33 -29
  2. package/dist/index.js +115 -45
  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">
@@ -322,27 +326,27 @@ Configuration priority: environment variables > config file > built-in defaults.
322
326
  │ (src/server.ts) │
323
327
  └──────────────┬────────────────┘
324
328
 
325
- ┌────────────────────────┼─────────────────────────┐
326
- │ │
327
- ┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
328
- │ Tool Handlers │ │ HCL Parsers │ │ Cost Engine │
329
- (src/tools/*.ts) │ │ (src/parsers/) │ (src/calculator/) │
330
- └─────────┬─────────┘ └───────────────────┘ └─────────┬─────────┘
331
-
332
- ┌─────────▼──────────────────────────────────────────────────▼─────────┐
333
- │ PricingEngine (router)
334
- (src/pricing/pricing-engine.ts)
335
- └───────┬────────────────────┬──────────────────────┬─────────────────┘
336
-
337
- ┌─────────▼──────┐ ┌─────────▼──────┐ ┌──────────▼──────┐
338
- │ AWS Bulk │ │ Azure Retail │ │ GCP Bundled
339
- │ Loader │ │ Client │ │ Loader
340
- │ (CSV + JSON) │ │ (REST API) │ │ (static JSON)
341
- └───────┬────────┘ └───────┬────────┘ └────────┬────────┘
342
- │ │
343
- ▼ ▼
344
- AWS Bulk Pricing Azure Retail API data/gcp-pricing/
345
- CSV (public) (public, no auth) (bundled files)
329
+ ┌────────────────────────┼───────────────────────┐
330
+ │ │
331
+ ┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
332
+ │ Tool Handlers │ │ HCL Parsers │ │ Cost Engine │
333
+ (src/tools/*.ts) │ │ (src/parsers/) │ (src/calculator/) │
334
+ └─────────┬─────────┘ └───────────────────┘ └─────────┬─────────┘
335
+
336
+ ┌─────────▼────────────────────────────────────────────────▼─────────┐
337
+ │ PricingEngine (router)
338
+ (src/pricing/pricing-engine.ts)
339
+ └──────┬───────────────────┬─────────────────────┬───────────────────┘
340
+
341
+ ┌────────▼───────┐ ┌────────▼───────┐ ┌────────▼───────┐
342
+ │ AWS Bulk │ │ Azure Retail │ │ GCP Bundled
343
+ │ Loader │ │ Client │ │ Loader
344
+ │ (CSV + JSON) │ │ (REST API) │ │ (static JSON)
345
+ └────────┬───────┘ └────────┬───────┘ └────────┬───────┘
346
+ │ │
347
+ ▼ ▼
348
+ AWS Bulk Pricing Azure Retail API data/gcp-pricing/
349
+ CSV (public) (public, no auth) (bundled files)
346
350
  ```
347
351
 
348
352
  ### Key Design Decisions
package/dist/index.js CHANGED
@@ -270,17 +270,30 @@ function extractUnit(priceDimensions) {
270
270
  }
271
271
  return "Hrs";
272
272
  }
273
+ function extractFromTerms(onDemandTerms, defaultUnit) {
274
+ for (const skuTerms of Object.values(onDemandTerms)) {
275
+ if (skuTerms?.priceDimensions) {
276
+ return {
277
+ price: extractUsdPrice(skuTerms.priceDimensions),
278
+ unit: extractUnit(skuTerms.priceDimensions)
279
+ };
280
+ }
281
+ for (const offerTerm of Object.values(skuTerms ?? {})) {
282
+ const dims = offerTerm?.priceDimensions;
283
+ if (dims) {
284
+ return {
285
+ price: extractUsdPrice(dims),
286
+ unit: extractUnit(dims)
287
+ };
288
+ }
289
+ }
290
+ }
291
+ return { price: 0, unit: defaultUnit };
292
+ }
273
293
  function normalizeAwsCompute(rawProduct, rawPrice, region) {
274
294
  const attrs = rawProduct?.attributes ?? {};
275
295
  const terms = rawPrice?.terms?.OnDemand ?? {};
276
- let price = 0;
277
- let unit = "Hrs";
278
- for (const term of Object.values(terms)) {
279
- const dims = term?.priceDimensions ?? {};
280
- price = extractUsdPrice(dims);
281
- unit = extractUnit(dims);
282
- break;
283
- }
296
+ const { price, unit } = extractFromTerms(terms, "Hrs");
284
297
  return {
285
298
  provider: "aws",
286
299
  service: "ec2",
@@ -295,7 +308,8 @@ function normalizeAwsCompute(rawProduct, rawPrice, region) {
295
308
  vcpu: attrs.vcpu ?? "",
296
309
  memory: attrs.memory ?? "",
297
310
  operating_system: attrs.operatingSystem ?? "Linux",
298
- tenancy: attrs.tenancy ?? "Shared"
311
+ tenancy: attrs.tenancy ?? "Shared",
312
+ pricing_source: "live"
299
313
  },
300
314
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
301
315
  };
@@ -303,14 +317,7 @@ function normalizeAwsCompute(rawProduct, rawPrice, region) {
303
317
  function normalizeAwsDatabase(rawProduct, rawPrice, region) {
304
318
  const attrs = rawProduct?.attributes ?? {};
305
319
  const terms = rawPrice?.terms?.OnDemand ?? {};
306
- let price = 0;
307
- let unit = "Hrs";
308
- for (const term of Object.values(terms)) {
309
- const dims = term?.priceDimensions ?? {};
310
- price = extractUsdPrice(dims);
311
- unit = extractUnit(dims);
312
- break;
313
- }
320
+ const { price, unit } = extractFromTerms(terms, "Hrs");
314
321
  return {
315
322
  provider: "aws",
316
323
  service: "rds",
@@ -325,7 +332,8 @@ function normalizeAwsDatabase(rawProduct, rawPrice, region) {
325
332
  database_engine: attrs.databaseEngine ?? "",
326
333
  deployment_option: attrs.deploymentOption ?? "Single-AZ",
327
334
  vcpu: attrs.vcpu ?? "",
328
- memory: attrs.memory ?? ""
335
+ memory: attrs.memory ?? "",
336
+ pricing_source: "live"
329
337
  },
330
338
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
331
339
  };
@@ -333,14 +341,7 @@ function normalizeAwsDatabase(rawProduct, rawPrice, region) {
333
341
  function normalizeAwsStorage(rawProduct, rawPrice, region) {
334
342
  const attrs = rawProduct?.attributes ?? {};
335
343
  const terms = rawPrice?.terms?.OnDemand ?? {};
336
- let price = 0;
337
- let unit = "GB-Mo";
338
- for (const term of Object.values(terms)) {
339
- const dims = term?.priceDimensions ?? {};
340
- price = extractUsdPrice(dims);
341
- unit = extractUnit(dims);
342
- break;
343
- }
344
+ const { price, unit } = extractFromTerms(terms, "GB-Mo");
344
345
  const volumeType = attrs.volumeApiName ?? attrs.volumeType ?? "gp3";
345
346
  return {
346
347
  provider: "aws",
@@ -354,7 +355,8 @@ function normalizeAwsStorage(rawProduct, rawPrice, region) {
354
355
  attributes: {
355
356
  volume_type: volumeType,
356
357
  max_iops: attrs.maxIopsvolume ?? "",
357
- max_throughput: attrs.maxThroughputvolume ?? ""
358
+ max_throughput: attrs.maxThroughputvolume ?? "",
359
+ pricing_source: "live"
358
360
  },
359
361
  effective_date: (/* @__PURE__ */ new Date()).toISOString()
360
362
  };
@@ -579,7 +581,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
579
581
  if (afterCsv) return afterCsv;
580
582
  }
581
583
  } catch (err) {
582
- logger.debug("AWS EC2 CSV streaming failed", {
584
+ logger.warn("AWS EC2 CSV streaming failed", {
583
585
  region,
584
586
  instanceType,
585
587
  err: err instanceof Error ? err.message : String(err)
@@ -595,7 +597,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
595
597
  }
596
598
  }
597
599
  } catch (err) {
598
- logger.debug("AWS bulk EC2 JSON fetch failed, using fallback", {
600
+ logger.warn("AWS bulk EC2 JSON fetch failed, using fallback", {
599
601
  region,
600
602
  instanceType,
601
603
  err: err instanceof Error ? err.message : String(err)
@@ -617,7 +619,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
617
619
  }
618
620
  }
619
621
  } catch (err) {
620
- logger.debug("AWS bulk RDS fetch failed, using fallback", {
622
+ logger.warn("AWS bulk RDS fetch failed, using fallback", {
621
623
  region,
622
624
  instanceClass,
623
625
  err: err instanceof Error ? err.message : String(err)
@@ -639,7 +641,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
639
641
  }
640
642
  }
641
643
  } catch (err) {
642
- logger.debug("AWS bulk EBS fetch failed, using fallback", {
644
+ logger.warn("AWS bulk EBS fetch failed, using fallback", {
643
645
  region,
644
646
  volumeType,
645
647
  err: err instanceof Error ? err.message : String(err)
@@ -918,7 +920,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
918
920
  extractEc2Price(bulk, instanceType, region, os) {
919
921
  const products = bulk?.products ?? {};
920
922
  const onDemand = bulk?.terms?.OnDemand ?? {};
921
- for (const [sku, product] of Object.entries(products)) {
923
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
924
+ for (const [sku, product] of sortedEntries) {
922
925
  const attrs = product?.attributes ?? {};
923
926
  if (attrs.instanceType === instanceType && (attrs.operatingSystem ?? "").toLowerCase() === os.toLowerCase() && attrs.tenancy === "Shared" && attrs.capacitystatus === "Used") {
924
927
  const priceTerms = onDemand[sku];
@@ -932,7 +935,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
932
935
  extractRdsPrice(bulk, instanceClass, region, engine) {
933
936
  const products = bulk?.products ?? {};
934
937
  const onDemand = bulk?.terms?.OnDemand ?? {};
935
- for (const [sku, product] of Object.entries(products)) {
938
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
939
+ for (const [sku, product] of sortedEntries) {
936
940
  const attrs = product?.attributes ?? {};
937
941
  if (attrs.instanceType === instanceClass && (attrs.databaseEngine ?? "").toLowerCase().includes(engine.toLowerCase()) && attrs.deploymentOption === "Single-AZ") {
938
942
  const priceTerms = onDemand[sku];
@@ -946,7 +950,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
946
950
  extractEbsPrice(bulk, volumeType, region) {
947
951
  const products = bulk?.products ?? {};
948
952
  const onDemand = bulk?.terms?.OnDemand ?? {};
949
- for (const [sku, product] of Object.entries(products)) {
953
+ const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
954
+ for (const [sku, product] of sortedEntries) {
950
955
  const attrs = product?.attributes ?? {};
951
956
  const apiName = (attrs.volumeApiName ?? "").toLowerCase();
952
957
  if (product?.productFamily === "Storage" && (apiName === volumeType.toLowerCase() || (attrs.volumeType ?? "").toLowerCase() === volumeType.toLowerCase())) {
@@ -1296,7 +1301,7 @@ var AzureRetailClient = class {
1296
1301
  return result;
1297
1302
  }
1298
1303
  } catch (err) {
1299
- logger.debug("Azure Retail API VM fetch failed, using fallback", {
1304
+ logger.warn("Azure Retail API VM fetch failed, using fallback", {
1300
1305
  region,
1301
1306
  vmSize,
1302
1307
  err: err instanceof Error ? err.message : String(err)
@@ -1311,15 +1316,18 @@ var AzureRetailClient = class {
1311
1316
  try {
1312
1317
  const armRegion = region.toLowerCase().replace(/\s+/g, "");
1313
1318
  const serviceName = `Azure Database for ${engine}`;
1314
- const filter = this.buildODataFilter(serviceName, armRegion, tier);
1315
- const items = await this.queryPricing(filter);
1316
- if (items.length > 0) {
1317
- const result = normalizeAzureDatabase(items[0]);
1319
+ const result = await this.queryDatabasePrice(tier, armRegion, serviceName);
1320
+ if (result) {
1318
1321
  this.cache.set(cacheKey, result, "azure", "db", region, CACHE_TTL2);
1319
1322
  return result;
1320
1323
  }
1324
+ logger.warn("Azure Retail API returned no matching DB pricing", {
1325
+ region,
1326
+ tier,
1327
+ engine
1328
+ });
1321
1329
  } catch (err) {
1322
- logger.debug("Azure Retail API DB fetch failed, using fallback", {
1330
+ logger.warn("Azure Retail API DB fetch failed, using fallback", {
1323
1331
  region,
1324
1332
  tier,
1325
1333
  err: err instanceof Error ? err.message : String(err)
@@ -1327,6 +1335,58 @@ var AzureRetailClient = class {
1327
1335
  }
1328
1336
  return this.fallbackDatabasePrice(tier, region, engine, cacheKey);
1329
1337
  }
1338
+ /**
1339
+ * Azure DB flexible server tier names follow the pattern:
1340
+ * GP_Standard_D2s_v3 → General Purpose, D-series, 2 vCPUs, v3
1341
+ * MO_Standard_E4ds_v5 → Memory Optimized, E-series, 4 vCPUs, v5
1342
+ *
1343
+ * The Retail API prices DB compute in two ways:
1344
+ * 1. Per-instance entries with armSkuName like "Standard_D2ds_v5" (total price)
1345
+ * 2. Per-vCore entries for a series (e.g., Dsv3Series_Compute) × vCPU count
1346
+ *
1347
+ * This method tries approach 1 first, then falls back to approach 2.
1348
+ */
1349
+ async queryDatabasePrice(tier, armRegion, serviceName) {
1350
+ const vmName = tier.replace(/^(GP|MO|BC|GEN)_/i, "");
1351
+ const exactFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and armSkuName eq '${vmName}'`;
1352
+ const exactItems = await this.queryPricing(exactFilter);
1353
+ if (exactItems.length > 0) {
1354
+ exactItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1355
+ const match = exactItems.find(
1356
+ (i) => (i.meterName ?? "").toLowerCase().includes("vcore")
1357
+ ) ?? exactItems[0];
1358
+ return normalizeAzureDatabase(match);
1359
+ }
1360
+ const parsed = this.parseVmSeries(vmName);
1361
+ if (!parsed) return null;
1362
+ const tierPrefix = tier.match(/^(GP|MO|BC|GEN)_/i)?.[1]?.toUpperCase();
1363
+ const seriesFamily = tierPrefix === "MO" ? "Memory_Optimized" : "General_Purpose";
1364
+ const seriesFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and contains(armSkuName, '${seriesFamily}') and contains(armSkuName, '${parsed.series}')`;
1365
+ const seriesItems = await this.queryPricing(seriesFilter);
1366
+ if (seriesItems.length > 0) {
1367
+ seriesItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1368
+ const perVcore = seriesItems[0].retailPrice ?? 0;
1369
+ const totalPrice = perVcore * parsed.vcpus;
1370
+ const adjusted = { ...seriesItems[0], retailPrice: totalPrice };
1371
+ return normalizeAzureDatabase(adjusted);
1372
+ }
1373
+ return null;
1374
+ }
1375
+ /**
1376
+ * Parse a VM name like "Standard_D2s_v3" into series info.
1377
+ * Returns { series: "Dsv3", vcpus: 2 } or null if unparseable.
1378
+ */
1379
+ parseVmSeries(vmName) {
1380
+ const match = vmName.match(
1381
+ /Standard_([A-Z])(\d+)([a-z]*)_v(\d+)/i
1382
+ );
1383
+ if (!match) return null;
1384
+ const [, family, vcpuStr, variant, version] = match;
1385
+ const vcpus = parseInt(vcpuStr, 10);
1386
+ if (isNaN(vcpus) || vcpus === 0) return null;
1387
+ const series = `${family}${variant}v${version}`;
1388
+ return { series, vcpus };
1389
+ }
1330
1390
  async getStoragePrice(diskType, region) {
1331
1391
  const cacheKey = this.buildCacheKey("disk", region, diskType);
1332
1392
  const cached = this.cache.get(cacheKey);
@@ -1336,12 +1396,17 @@ var AzureRetailClient = class {
1336
1396
  const filter = this.buildODataFilter("Storage", armRegion, diskType);
1337
1397
  const items = await this.queryPricing(filter);
1338
1398
  if (items.length > 0) {
1399
+ items.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
1339
1400
  const result = normalizeAzureStorage(items[0]);
1340
1401
  this.cache.set(cacheKey, result, "azure", "storage", region, CACHE_TTL2);
1341
1402
  return result;
1342
1403
  }
1404
+ logger.warn("Azure Retail API returned no matching storage pricing", {
1405
+ region,
1406
+ diskType
1407
+ });
1343
1408
  } catch (err) {
1344
- logger.debug("Azure Retail API storage fetch failed, using fallback", {
1409
+ logger.warn("Azure Retail API storage fetch failed, using fallback", {
1345
1410
  region,
1346
1411
  diskType,
1347
1412
  err: err instanceof Error ? err.message : String(err)
@@ -1442,16 +1507,19 @@ var AzureRetailClient = class {
1442
1507
  */
1443
1508
  pickVmItem(items, vmSize, os) {
1444
1509
  if (items.length === 0) return null;
1510
+ const sorted = [...items].sort(
1511
+ (a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? "")
1512
+ );
1445
1513
  const vmLower = vmSize.toLowerCase();
1446
1514
  const isWindows = os.toLowerCase().includes("windows");
1447
- for (const item of items) {
1515
+ for (const item of sorted) {
1448
1516
  const skuLower = (item.skuName ?? "").toLowerCase();
1449
1517
  const hasWindows = skuLower.includes("windows");
1450
1518
  if (skuLower.includes(vmLower) && (isWindows && hasWindows || !isWindows && !hasWindows)) {
1451
1519
  return item;
1452
1520
  }
1453
1521
  }
1454
- return items[0];
1522
+ return sorted[0];
1455
1523
  }
1456
1524
  // -------------------------------------------------------------------------
1457
1525
  // Internal helpers – size interpolation
@@ -2920,7 +2988,7 @@ function findNearestInstance(spec, targetProvider) {
2920
2988
  if (candidate.category === spec.category) {
2921
2989
  score += SCORE_CATEGORY;
2922
2990
  }
2923
- if (score > bestScore) {
2991
+ if (score > bestScore || score === bestScore && bestType !== null && candidate.instance_type < bestType) {
2924
2992
  bestScore = score;
2925
2993
  bestType = candidate.instance_type;
2926
2994
  }
@@ -3657,7 +3725,9 @@ var CostEngine = class {
3657
3725
  total_monthly: Math.round(totalMonthly * 100) / 100,
3658
3726
  total_yearly: Math.round(totalMonthly * 12 * 100) / 100,
3659
3727
  currency: "USD",
3660
- by_service: byService,
3728
+ by_service: Object.fromEntries(
3729
+ Object.entries(byService).map(([k, v]) => [k, Math.round(v * 100) / 100])
3730
+ ),
3661
3731
  by_resource: estimates,
3662
3732
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3663
3733
  warnings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jadenrazo/cloudcost-mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "MCP server for multi-cloud cost analysis of Terraform codebases",
5
5
  "type": "module",
6
6
  "license": "MIT",