@jadenrazo/cloudcost-mcp 1.0.0 → 1.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.
package/dist/index.js CHANGED
@@ -7,12 +7,16 @@ import {
7
7
  analyzeTerraform,
8
8
  analyzeTerraformSchema,
9
9
  bool,
10
+ coerceToString,
10
11
  compareProviders,
11
12
  compareProvidersSchema,
12
13
  convertBreakdownCurrency,
13
14
  convertCurrency,
15
+ currencyField,
14
16
  estimateCost,
15
17
  estimateCostSchema,
18
+ getExchangeRateInfo,
19
+ iacFilesSchema,
16
20
  loadConfig,
17
21
  logger,
18
22
  mapInstance,
@@ -21,14 +25,23 @@ import {
21
25
  optimizeCost,
22
26
  optimizeCostSchema,
23
27
  parseTerraform,
28
+ planJsonSchema,
29
+ providerEnum,
30
+ safeJsonParse,
31
+ sanitizeForMessage,
24
32
  setLogLevel,
33
+ shortStringSchema,
34
+ stateJsonSchema,
25
35
  str,
36
+ tfvarsField,
26
37
  whatIf,
27
38
  whatIfSchema
28
- } from "./chunk-E7KOWAMW.js";
39
+ } from "./chunk-CGMU4ETN.js";
29
40
  import {
41
+ getFallbackMetadata,
42
+ getPricingMetadata,
30
43
  getResourceEquivalents
31
- } from "./chunk-TRRAOOVF.js";
44
+ } from "./chunk-LWTVE7UY.js";
32
45
 
33
46
  // src/server.ts
34
47
  import { createRequire } from "module";
@@ -74,7 +87,7 @@ var getEquivalentsSchema = z.object({
74
87
  "Instance type / VM size to also map across providers (e.g. t3.large, Standard_D4s_v3)"
75
88
  )
76
89
  });
77
- async function getEquivalents(params) {
90
+ function getEquivalents(params) {
78
91
  const sourceProvider = params.source_provider;
79
92
  let resourceEquivalents;
80
93
  if (params.target_provider) {
@@ -120,8 +133,11 @@ var getPricingSchema = z2.object({
120
133
  "network",
121
134
  "load_balancer",
122
135
  "nat_gateway",
123
- "kubernetes"
124
- ]).describe("Service category (network defaults to nat_gateway for backward compatibility)"),
136
+ "kubernetes",
137
+ "gpu"
138
+ ]).describe(
139
+ "Service category (network defaults to nat_gateway for backward compatibility; gpu returns accelerator pricing where supported)"
140
+ ),
125
141
  resource_type: z2.string().describe(
126
142
  "Instance type, storage type, or resource identifier (e.g. t3.large, gp3, Standard_D4s_v3)"
127
143
  ),
@@ -136,7 +152,8 @@ async function getPricing(params, pricingEngine) {
136
152
  network: "nat-gateway",
137
153
  load_balancer: "load-balancer",
138
154
  nat_gateway: "nat-gateway",
139
- kubernetes: "kubernetes"
155
+ kubernetes: "kubernetes",
156
+ gpu: "gpu"
140
157
  };
141
158
  const service = serviceMap[params.service] ?? params.service;
142
159
  const rawPrice = await pricingEngine.getPrice(
@@ -157,7 +174,14 @@ async function getPricing(params, pricingEngine) {
157
174
  price.attributes = attributes;
158
175
  }
159
176
  }
160
- return { price };
177
+ const meta = getFallbackMetadata(provider);
178
+ const fallback_metadata = meta ? {
179
+ last_updated: meta.last_updated,
180
+ source: meta.source,
181
+ sku_count: meta.sku_count,
182
+ refresh_script_version: meta.refresh_script_version
183
+ } : void 0;
184
+ return fallback_metadata ? { price, fallback_metadata } : { price };
161
185
  }
162
186
 
163
187
  // src/tools/analyze-plan.ts
@@ -307,10 +331,10 @@ function buildInventory(resources, warnings) {
307
331
  function parseTerraformPlan(planJson) {
308
332
  let plan;
309
333
  try {
310
- plan = JSON.parse(planJson);
334
+ plan = safeJsonParse(planJson);
311
335
  } catch (err) {
312
336
  const msg = err instanceof Error ? err.message : String(err);
313
- throw new Error(`Invalid plan JSON: ${msg}`);
337
+ throw new Error(`Invalid plan JSON: ${sanitizeForMessage(msg, 512)}`);
314
338
  }
315
339
  if (!Array.isArray(plan.resource_changes)) {
316
340
  throw new Error("Invalid plan JSON: missing or non-array resource_changes field");
@@ -375,7 +399,9 @@ function parseTerraformPlan(planJson) {
375
399
 
376
400
  // src/tools/analyze-plan.ts
377
401
  var analyzePlanSchema = z3.object({
378
- plan_json: z3.string().describe("Output of 'terraform show -json <planfile>' or 'terraform plan -json'"),
402
+ plan_json: planJsonSchema.describe(
403
+ "Output of 'terraform show -json <planfile>' or 'terraform plan -json'"
404
+ ),
379
405
  provider: z3.enum(["aws", "azure", "gcp"]).optional().describe("Target provider for cost estimation. If omitted, auto-detected from plan."),
380
406
  region: z3.string().optional().describe("Target region. If omitted, detected from plan resources."),
381
407
  currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency")
@@ -434,7 +460,8 @@ async function analyzePlan(params, pricingEngine, config) {
434
460
  total_delta_monthly: round2(totalAfter - totalBefore),
435
461
  summary: analysis.summary,
436
462
  resource_diffs: resourceDiffs,
437
- warnings: [...new Set(warnings)]
463
+ warnings: [...new Set(warnings)],
464
+ pricing_metadata: getPricingMetadata(targetProvider)
438
465
  };
439
466
  if (currency !== "USD") {
440
467
  result = applyResultCurrency(result, currency);
@@ -455,6 +482,7 @@ function applyResultCurrency(result, currency) {
455
482
  return {
456
483
  ...result,
457
484
  currency,
485
+ exchange_rate: getExchangeRateInfo(currency),
458
486
  total_before_monthly: round2(convertCurrency(result.total_before_monthly, currency)),
459
487
  total_after_monthly: round2(convertCurrency(result.total_after_monthly, currency)),
460
488
  total_delta_monthly: round2(convertCurrency(result.total_delta_monthly, currency)),
@@ -559,7 +587,7 @@ function extractTags2(attrs) {
559
587
  const result = {};
560
588
  for (const [k, v] of Object.entries(tags)) {
561
589
  if (typeof v === "string") result[k] = v;
562
- else if (v !== null && v !== void 0) result[k] = String(v);
590
+ else if (v !== null && v !== void 0) result[k] = coerceToString(v);
563
591
  }
564
592
  return result;
565
593
  }
@@ -567,17 +595,18 @@ function parseTerraformState(stateJson) {
567
595
  const warnings = [];
568
596
  let state;
569
597
  try {
570
- state = JSON.parse(stateJson);
598
+ state = safeJsonParse(stateJson);
571
599
  } catch (err) {
572
600
  const msg = err instanceof Error ? err.message : String(err);
573
- logger.error("Failed to parse Terraform state JSON", { error: msg });
601
+ const safeMsg = sanitizeForMessage(msg, 512);
602
+ logger.error("Failed to parse Terraform state JSON", { error: safeMsg });
574
603
  return {
575
604
  provider: "aws",
576
605
  region: PROVIDER_DEFAULTS.aws,
577
606
  resources: [],
578
607
  total_count: 0,
579
608
  by_type: {},
580
- parse_warnings: [`Invalid state JSON: ${msg}`]
609
+ parse_warnings: [`Invalid state JSON: ${safeMsg}`]
581
610
  };
582
611
  }
583
612
  if (!Array.isArray(state.resources)) {
@@ -657,19 +686,14 @@ function parseTerraformState(stateJson) {
657
686
 
658
687
  // src/tools/compare-actual.ts
659
688
  var compareActualSchema = z4.object({
660
- state_json: z4.string().describe("Contents of terraform.tfstate file (JSON)"),
661
- files: z4.array(
662
- z4.object({
663
- path: z4.string().describe("File path"),
664
- content: z4.string().describe("File content (HCL)")
665
- })
666
- ).optional().describe("Optional Terraform files to compare planned costs against actual"),
667
- tfvars: z4.string().optional().describe("Contents of terraform.tfvars file"),
668
- provider: z4.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider override. Defaults to auto-detecting from the state."),
669
- region: z4.string().optional().describe(
689
+ state_json: stateJsonSchema.describe("Contents of terraform.tfstate file (JSON)"),
690
+ files: iacFilesSchema.optional().describe("Optional Terraform files to compare planned costs against actual"),
691
+ tfvars: tfvarsField,
692
+ provider: providerEnum.optional().describe("Target cloud provider override. Defaults to auto-detecting from the state."),
693
+ region: shortStringSchema.optional().describe(
670
694
  "Target region override for pricing. Defaults to the region detected from the state."
671
695
  ),
672
- currency: z4.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
696
+ currency: currencyField
673
697
  });
674
698
  async function compareActual(params, pricingEngine, config) {
675
699
  const warnings = [];
@@ -700,7 +724,8 @@ async function compareActual(params, pricingEngine, config) {
700
724
  resource_count: stateInventory.total_count,
701
725
  by_resource: actualByResource
702
726
  },
703
- warnings: []
727
+ warnings: [],
728
+ pricing_metadata: getPricingMetadata(targetProvider)
704
729
  };
705
730
  if (params.files && params.files.length > 0) {
706
731
  const plannedInventory = await parseTerraform(params.files, params.tfvars);
@@ -734,6 +759,7 @@ async function compareActual(params, pricingEngine, config) {
734
759
  };
735
760
  }
736
761
  if (currency !== "USD") {
762
+ result.exchange_rate = getExchangeRateInfo(currency);
737
763
  const actualConverted = convertBreakdownCurrency(actualBreakdown, currency);
738
764
  result.actual_costs.total_monthly = Math.round(actualConverted.total_monthly * 100) / 100;
739
765
  result.actual_costs.total_yearly = Math.round(actualConverted.total_monthly * 12 * 100) / 100;
@@ -785,16 +811,11 @@ function priceTrends(params, cache) {
785
811
  // src/tools/detect-anomalies.ts
786
812
  import { z as z6 } from "zod";
787
813
  var detectAnomaliesSchema = z6.object({
788
- files: z6.array(
789
- z6.object({
790
- path: z6.string().describe("File path"),
791
- content: z6.string().describe("File content")
792
- })
793
- ),
794
- tfvars: z6.string().optional().describe("Variable overrides"),
795
- provider: z6.enum(["aws", "azure", "gcp"]).describe("Target cloud provider"),
796
- region: z6.string().optional().describe("Target region"),
797
- currency: z6.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD."),
814
+ files: iacFilesSchema,
815
+ tfvars: tfvarsField,
816
+ provider: providerEnum.describe("Target cloud provider"),
817
+ region: shortStringSchema.optional().describe("Target region"),
818
+ currency: currencyField,
798
819
  budget_monthly: z6.number().optional().describe("Monthly budget threshold in USD"),
799
820
  budget_per_resource: z6.number().optional().describe("Per-resource cost threshold in USD"),
800
821
  price_change_threshold: z6.number().optional().default(10).describe("Alert on price changes exceeding this percentage")
@@ -860,12 +881,13 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
860
881
  }
861
882
  for (const est of byResource) {
862
883
  if (params.budget_per_resource != null && est.monthly_cost > params.budget_per_resource) {
884
+ const safeId = sanitizeForMessage(est.resource_id, 256);
863
885
  anomalies.push({
864
886
  type: "budget_exceeded",
865
887
  severity: "high",
866
- resource: est.resource_id,
867
- resource_type: est.resource_type,
868
- message: `Resource ${est.resource_id} costs $${est.monthly_cost.toFixed(2)}/mo, exceeding per-resource budget of $${params.budget_per_resource.toFixed(2)}`,
888
+ resource: safeId,
889
+ resource_type: sanitizeForMessage(est.resource_type, 128),
890
+ message: `Resource ${safeId} costs $${est.monthly_cost.toFixed(2)}/mo, exceeding per-resource budget of $${params.budget_per_resource.toFixed(2)}`,
869
891
  details: {
870
892
  current_cost: est.monthly_cost,
871
893
  threshold: params.budget_per_resource
@@ -881,19 +903,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
881
903
  const priceChange = cache.getPriceChange(targetProvider, service, resourceKey, targetRegion);
882
904
  if (priceChange && Math.abs(priceChange.change_percent) > priceChangeThreshold) {
883
905
  const direction = priceChange.change_percent > 0 ? "increased" : "decreased";
906
+ const safeId = sanitizeForMessage(est.resource_id, 256);
884
907
  anomalies.push({
885
908
  type: "price_change",
886
909
  severity: Math.abs(priceChange.change_percent) > 20 ? "high" : "medium",
887
- resource: est.resource_id,
888
- resource_type: est.resource_type,
889
- message: `Pricing for ${est.resource_id} has ${direction} by ${Math.abs(priceChange.change_percent).toFixed(1)}%`,
910
+ resource: safeId,
911
+ resource_type: sanitizeForMessage(est.resource_type, 128),
912
+ message: `Pricing for ${safeId} has ${direction} by ${Math.abs(priceChange.change_percent).toFixed(1)}%`,
890
913
  details: {
891
914
  current_cost: est.monthly_cost,
892
915
  price_change_percent: priceChange.change_percent
893
916
  }
894
917
  });
895
918
  recommendations.push(
896
- `Review pricing change for ${est.resource_id} (${Math.abs(priceChange.change_percent).toFixed(1)}% ${direction}). Consider locking in reserved pricing.`
919
+ `Review pricing change for ${safeId} (${Math.abs(priceChange.change_percent).toFixed(1)}% ${direction}). Consider locking in reserved pricing.`
897
920
  );
898
921
  }
899
922
  }
@@ -901,19 +924,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
901
924
  for (const est of byResource) {
902
925
  const sharePercent = est.monthly_cost / totalMonthly * 100;
903
926
  if (sharePercent > 50 && byResource.length > 1) {
927
+ const safeId = sanitizeForMessage(est.resource_id, 256);
904
928
  anomalies.push({
905
929
  type: "cost_concentration",
906
930
  severity: "medium",
907
- resource: est.resource_id,
908
- resource_type: est.resource_type,
909
- message: `Resource ${est.resource_id} accounts for ${sharePercent.toFixed(1)}% of total cost (concentration risk)`,
931
+ resource: safeId,
932
+ resource_type: sanitizeForMessage(est.resource_type, 128),
933
+ message: `Resource ${safeId} accounts for ${sharePercent.toFixed(1)}% of total cost (concentration risk)`,
910
934
  details: {
911
935
  current_cost: est.monthly_cost,
912
936
  cost_share_percent: sharePercent
913
937
  }
914
938
  });
915
939
  recommendations.push(
916
- `${est.resource_id} dominates total spend at ${sharePercent.toFixed(1)}%. Evaluate whether workload can be distributed or right-sized.`
940
+ `${safeId} dominates total spend at ${sharePercent.toFixed(1)}%. Evaluate whether workload can be distributed or right-sized.`
917
941
  );
918
942
  }
919
943
  }
@@ -921,18 +945,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
921
945
  for (const est of byResource) {
922
946
  const instanceType = resolveInstanceType(est);
923
947
  if (isOversizedInstance(instanceType)) {
948
+ const safeId = sanitizeForMessage(est.resource_id, 256);
949
+ const safeType = instanceType ? sanitizeForMessage(instanceType, 64) : "(unknown)";
924
950
  anomalies.push({
925
951
  type: "potential_oversizing",
926
952
  severity: "low",
927
- resource: est.resource_id,
928
- resource_type: est.resource_type,
929
- message: `Instance ${est.resource_id} uses ${instanceType} which may be over-provisioned`,
953
+ resource: safeId,
954
+ resource_type: sanitizeForMessage(est.resource_type, 128),
955
+ message: `Instance ${safeId} uses ${safeType} which may be over-provisioned`,
930
956
  details: {
931
957
  current_cost: est.monthly_cost
932
958
  }
933
959
  });
934
960
  recommendations.push(
935
- `Consider whether ${est.resource_id} (${instanceType}) needs all allocated resources. Smaller instance types can significantly reduce cost.`
961
+ `Consider whether ${safeId} (${safeType}) needs all allocated resources. Smaller instance types can significantly reduce cost.`
936
962
  );
937
963
  }
938
964
  }
@@ -962,131 +988,323 @@ function resolveInstanceType(est) {
962
988
  return void 0;
963
989
  }
964
990
 
991
+ // src/tools/check-cost-budget.ts
992
+ import { z as z7 } from "zod";
993
+ var checkCostBudgetSchema = z7.object({
994
+ files: iacFilesSchema.describe(
995
+ "IaC files to evaluate (Terraform, CloudFormation, Pulumi, or Bicep/ARM)"
996
+ ),
997
+ tfvars: tfvarsField,
998
+ provider: providerEnum.optional().describe(
999
+ "Target cloud provider. Defaults to the provider detected in the files; if no provider can be detected and none is passed, the call returns a structured error rather than silently picking AWS."
1000
+ ),
1001
+ region: shortStringSchema.optional().describe("Target region. Defaults to the region detected in the files."),
1002
+ currency: currencyField.describe(
1003
+ "Currency for the threshold comparisons and output. Defaults to USD."
1004
+ ),
1005
+ max_monthly: z7.number().positive().finite().optional().describe(
1006
+ "Aggregate monthly spend threshold. If the estimated total exceeds this, verdict is 'block'. Falls back to CLOUDCOST_GUARDRAIL_MAX_MONTHLY env var, then CLOUDCOST_BUDGET_MONTHLY."
1007
+ ),
1008
+ max_per_resource: z7.number().positive().finite().optional().describe(
1009
+ "Per-resource monthly cost threshold. Any single resource above this forces a 'block' verdict. Falls back to CLOUDCOST_GUARDRAIL_MAX_PER_RESOURCE env var, then CLOUDCOST_BUDGET_PER_RESOURCE."
1010
+ ),
1011
+ warn_ratio: z7.number().gt(0).max(1).optional().describe(
1012
+ "Fraction of a threshold at which to emit 'warn' rather than 'allow'. Must be > 0 and \u2264 1. Default 0.8 (warn at 80%)."
1013
+ )
1014
+ });
1015
+ function pickThreshold(fromParams, fromConfig, fromBudget) {
1016
+ if (fromParams != null) return { value: fromParams, source: "params" };
1017
+ if (fromConfig != null) return { value: fromConfig, source: "config" };
1018
+ if (fromBudget != null) return { value: fromBudget, source: "budget" };
1019
+ return { value: void 0, source: "none" };
1020
+ }
1021
+ function resolveThresholds(params, config) {
1022
+ const guardrail = config.guardrail ?? {};
1023
+ const budget = config.budget ?? {};
1024
+ const monthly = pickThreshold(params.max_monthly, guardrail.max_monthly, budget.monthly_limit);
1025
+ const perResource = pickThreshold(
1026
+ params.max_per_resource,
1027
+ guardrail.max_per_resource,
1028
+ budget.per_resource_limit
1029
+ );
1030
+ return {
1031
+ max_monthly: monthly.value,
1032
+ max_per_resource: perResource.value,
1033
+ warn_ratio: params.warn_ratio ?? guardrail.warn_ratio ?? 0.8,
1034
+ source: { max_monthly: monthly.source, max_per_resource: perResource.source }
1035
+ };
1036
+ }
1037
+ async function checkCostBudget(params, pricingEngine, config) {
1038
+ const thresholds = resolveThresholds(params, config);
1039
+ const currency = params.currency ?? "USD";
1040
+ const inventory = await parseTerraform(params.files, params.tfvars);
1041
+ const resolvedProvider = params.provider ?? inventory.provider;
1042
+ if (!resolvedProvider) {
1043
+ return {
1044
+ verdict: "block",
1045
+ total_monthly: 0,
1046
+ currency,
1047
+ resource_count: inventory.resources.length,
1048
+ thresholds,
1049
+ reasons: [
1050
+ "provider could not be determined from the input \u2014 pass `provider` explicitly (aws|azure|gcp)"
1051
+ ],
1052
+ blocking_resources: [],
1053
+ warning_resources: [],
1054
+ summary: "block: provider could not be determined",
1055
+ error: "provider_unresolved"
1056
+ };
1057
+ }
1058
+ const targetProvider = resolvedProvider;
1059
+ const targetRegion = params.region ?? mapRegion(inventory.region, inventory.provider, targetProvider);
1060
+ const pricingMetadata = getPricingMetadata(targetProvider);
1061
+ const exchangeRate = currency !== "USD" ? getExchangeRateInfo(currency) : void 0;
1062
+ const costEngine = new CostEngine(pricingEngine, config);
1063
+ let breakdown = await costEngine.calculateBreakdown(
1064
+ inventory.resources,
1065
+ targetProvider,
1066
+ targetRegion
1067
+ );
1068
+ if (currency !== "USD") {
1069
+ breakdown = convertBreakdownCurrency(breakdown, currency);
1070
+ }
1071
+ const totalMonthly = breakdown.total_monthly;
1072
+ const resourceCount = breakdown.by_resource.length;
1073
+ if (!Number.isFinite(totalMonthly)) {
1074
+ return {
1075
+ verdict: "block",
1076
+ total_monthly: 0,
1077
+ currency,
1078
+ resource_count: resourceCount,
1079
+ thresholds,
1080
+ reasons: ["total cost computed as non-finite value \u2014 internal pricing error"],
1081
+ blocking_resources: [],
1082
+ warning_resources: [],
1083
+ summary: "block: non-finite total cost",
1084
+ pricing_metadata: pricingMetadata,
1085
+ ...exchangeRate && { exchange_rate: exchangeRate },
1086
+ error: "non_finite_total"
1087
+ };
1088
+ }
1089
+ const blocking = [];
1090
+ const warning = [];
1091
+ const reasons = [];
1092
+ if (thresholds.max_monthly == null && thresholds.max_per_resource == null) {
1093
+ return {
1094
+ verdict: "allow",
1095
+ total_monthly: totalMonthly,
1096
+ currency,
1097
+ resource_count: resourceCount,
1098
+ thresholds,
1099
+ reasons: [
1100
+ "no budget thresholds configured \u2014 pass max_monthly / max_per_resource or set CLOUDCOST_GUARDRAIL_MAX_MONTHLY / CLOUDCOST_GUARDRAIL_MAX_PER_RESOURCE"
1101
+ ],
1102
+ blocking_resources: [],
1103
+ warning_resources: [],
1104
+ summary: `allow: no thresholds configured (${resourceCount} resources, ${formatMoney(totalMonthly, currency)}/mo)`,
1105
+ pricing_metadata: pricingMetadata,
1106
+ ...exchangeRate && { exchange_rate: exchangeRate }
1107
+ };
1108
+ }
1109
+ const totalOver = thresholds.max_monthly != null && totalMonthly > thresholds.max_monthly;
1110
+ const totalWarn = !totalOver && thresholds.max_monthly != null && totalMonthly >= thresholds.max_monthly * thresholds.warn_ratio;
1111
+ if (totalOver) {
1112
+ reasons.push(
1113
+ `total ${formatMoney(totalMonthly, currency)}/mo exceeds threshold ${formatMoney(thresholds.max_monthly, currency)}/mo`
1114
+ );
1115
+ } else if (totalWarn) {
1116
+ const pct = Math.round(totalMonthly / thresholds.max_monthly * 100);
1117
+ reasons.push(
1118
+ `total ${formatMoney(totalMonthly, currency)}/mo is ${pct}% of threshold ${formatMoney(thresholds.max_monthly, currency)}/mo`
1119
+ );
1120
+ }
1121
+ if (thresholds.max_per_resource != null) {
1122
+ for (const est of breakdown.by_resource) {
1123
+ if (!Number.isFinite(est.monthly_cost)) continue;
1124
+ const safeId = sanitizeForMessage(est.resource_id, 256);
1125
+ const safeType = sanitizeForMessage(est.resource_type, 128);
1126
+ if (est.monthly_cost > thresholds.max_per_resource) {
1127
+ blocking.push({
1128
+ resource_id: safeId,
1129
+ resource_type: safeType,
1130
+ monthly_cost: est.monthly_cost,
1131
+ threshold: thresholds.max_per_resource,
1132
+ reason: `${formatMoney(est.monthly_cost, currency)}/mo exceeds per-resource limit ${formatMoney(thresholds.max_per_resource, currency)}/mo`
1133
+ });
1134
+ } else if (est.monthly_cost >= thresholds.max_per_resource * thresholds.warn_ratio) {
1135
+ const pct = Math.round(est.monthly_cost / thresholds.max_per_resource * 100);
1136
+ warning.push({
1137
+ resource_id: safeId,
1138
+ resource_type: safeType,
1139
+ monthly_cost: est.monthly_cost,
1140
+ threshold: thresholds.max_per_resource,
1141
+ reason: `${formatMoney(est.monthly_cost, currency)}/mo is ${pct}% of per-resource limit ${formatMoney(thresholds.max_per_resource, currency)}/mo`
1142
+ });
1143
+ }
1144
+ }
1145
+ }
1146
+ let verdict = "allow";
1147
+ if (totalOver || blocking.length > 0) {
1148
+ verdict = "block";
1149
+ } else if (totalWarn || warning.length > 0) {
1150
+ verdict = "warn";
1151
+ }
1152
+ const summaryParts = [
1153
+ `${verdict}: ${formatMoney(totalMonthly, currency)}/mo across ${resourceCount} resource${resourceCount === 1 ? "" : "s"}`
1154
+ ];
1155
+ if (totalOver) {
1156
+ summaryParts.push(`total over threshold ${formatMoney(thresholds.max_monthly, currency)}`);
1157
+ }
1158
+ if (blocking.length) {
1159
+ summaryParts.push(
1160
+ `${blocking.length} resource${blocking.length === 1 ? "" : "s"} over per-resource limit`
1161
+ );
1162
+ }
1163
+ if (warning.length) {
1164
+ summaryParts.push(
1165
+ `${warning.length} resource${warning.length === 1 ? "" : "s"} near per-resource limit`
1166
+ );
1167
+ }
1168
+ return {
1169
+ verdict,
1170
+ total_monthly: totalMonthly,
1171
+ currency,
1172
+ resource_count: resourceCount,
1173
+ thresholds,
1174
+ reasons,
1175
+ blocking_resources: blocking,
1176
+ warning_resources: warning,
1177
+ summary: summaryParts.join("; "),
1178
+ pricing_metadata: pricingMetadata,
1179
+ ...exchangeRate && { exchange_rate: exchangeRate }
1180
+ };
1181
+ }
1182
+ function formatMoney(amount, currency) {
1183
+ const safe = Number.isFinite(amount) ? amount : 0;
1184
+ const symbol = currency === "USD" ? "$" : `${currency} `;
1185
+ return `${symbol}${safe.toFixed(2)}`;
1186
+ }
1187
+
965
1188
  // src/tools/index.ts
1189
+ function jsonResult(value, isError) {
1190
+ return {
1191
+ content: [{ type: "text", text: JSON.stringify(value) }],
1192
+ ...isError ? { isError: true } : {}
1193
+ };
1194
+ }
1195
+ function registerJsonTool(server, name, description, schema, handler, resultIsError) {
1196
+ server.registerTool(
1197
+ name,
1198
+ {
1199
+ description,
1200
+ inputSchema: schema.shape,
1201
+ annotations: { readOnlyHint: true }
1202
+ },
1203
+ async (params) => {
1204
+ try {
1205
+ const result = await handler(params);
1206
+ return jsonResult(result, resultIsError?.(result) ?? false);
1207
+ } catch (err) {
1208
+ const message = err instanceof Error ? err.message : String(err);
1209
+ logger.error(`tool ${name} failed`, { error: message });
1210
+ return jsonResult({ error: message }, true);
1211
+ }
1212
+ }
1213
+ );
1214
+ }
966
1215
  function registerTools(server, config) {
967
1216
  const cache = new PricingCache(config.cache.db_path);
968
1217
  const pricingEngine = new PricingEngine(cache, config);
969
- server.tool(
1218
+ registerJsonTool(
1219
+ server,
970
1220
  "analyze_terraform",
971
1221
  "Parse Terraform HCL files and extract a resource inventory with provider detection, variable resolution, and cost-relevant attribute extraction",
972
- analyzeTerraformSchema.shape,
973
- async (params) => {
974
- const result = await analyzeTerraform(params);
975
- return {
976
- content: [{ type: "text", text: JSON.stringify(result) }]
977
- };
978
- }
1222
+ analyzeTerraformSchema,
1223
+ (params) => analyzeTerraform(params)
979
1224
  );
980
- server.tool(
1225
+ registerJsonTool(
1226
+ server,
981
1227
  "estimate_cost",
982
1228
  "Estimate monthly and yearly cloud costs for Terraform resources on a specific provider. Returns a full cost breakdown by resource and service category.",
983
- estimateCostSchema.shape,
984
- async (params) => {
985
- const result = await estimateCost(params, pricingEngine, config);
986
- return {
987
- content: [{ type: "text", text: JSON.stringify(result) }]
988
- };
989
- }
1229
+ estimateCostSchema,
1230
+ (params) => estimateCost(params, pricingEngine, config)
990
1231
  );
991
- server.tool(
1232
+ registerJsonTool(
1233
+ server,
992
1234
  "compare_providers",
993
1235
  "Run a full cost comparison across AWS, Azure, and GCP for a set of Terraform resources. Returns a formatted report and raw comparison data including savings potential.",
994
- compareProvidersSchema.shape,
995
- async (params) => {
996
- const result = await compareProviders(params, pricingEngine, config);
997
- return {
998
- content: [{ type: "text", text: JSON.stringify(result) }]
999
- };
1000
- }
1236
+ compareProvidersSchema,
1237
+ (params) => compareProviders(params, pricingEngine, config)
1001
1238
  );
1002
- server.tool(
1239
+ registerJsonTool(
1240
+ server,
1003
1241
  "get_equivalents",
1004
1242
  "Look up the equivalent Terraform resource types across cloud providers. Optionally also maps an instance type / VM size to the nearest equivalent on target providers.",
1005
- getEquivalentsSchema.shape,
1006
- async (params) => {
1007
- const result = await getEquivalents(params);
1008
- return {
1009
- content: [{ type: "text", text: JSON.stringify(result) }]
1010
- };
1011
- }
1243
+ getEquivalentsSchema,
1244
+ (params) => getEquivalents(params)
1012
1245
  );
1013
- server.tool(
1246
+ registerJsonTool(
1247
+ server,
1014
1248
  "get_pricing",
1015
1249
  "Direct pricing lookup for a specific cloud provider, service, resource type, and region. Returns the normalised unit price with metadata.",
1016
- getPricingSchema.shape,
1017
- async (params) => {
1018
- const result = await getPricing(params, pricingEngine);
1019
- return {
1020
- content: [{ type: "text", text: JSON.stringify(result) }]
1021
- };
1022
- }
1250
+ getPricingSchema,
1251
+ (params) => getPricing(params, pricingEngine)
1023
1252
  );
1024
- server.tool(
1253
+ registerJsonTool(
1254
+ server,
1025
1255
  "optimize_cost",
1026
1256
  "Analyse Terraform resources and return cost optimisation recommendations including right-sizing suggestions, reserved pricing comparisons, and cross-provider savings opportunities.",
1027
- optimizeCostSchema.shape,
1028
- async (params) => {
1029
- const result = await optimizeCost(params, pricingEngine, config);
1030
- return {
1031
- content: [{ type: "text", text: JSON.stringify(result) }]
1032
- };
1033
- }
1257
+ optimizeCostSchema,
1258
+ (params) => optimizeCost(params, pricingEngine, config)
1034
1259
  );
1035
- server.tool(
1260
+ registerJsonTool(
1261
+ server,
1036
1262
  "what_if",
1037
1263
  "Estimate cost impact of infrastructure changes without modifying Terraform files. Applies attribute overrides to a cloned resource set and returns a per-resource and aggregate cost diff.",
1038
- whatIfSchema.shape,
1039
- async (params) => {
1040
- const result = await whatIf(params, pricingEngine, config);
1041
- return {
1042
- content: [{ type: "text", text: JSON.stringify(result) }]
1043
- };
1044
- }
1264
+ whatIfSchema,
1265
+ (params) => whatIf(params, pricingEngine, config)
1045
1266
  );
1046
- server.tool(
1267
+ registerJsonTool(
1268
+ server,
1047
1269
  "analyze_plan",
1048
1270
  "Parse Terraform plan JSON (from 'terraform show -json' or 'terraform plan -json') and return a cost-of-change analysis showing before/after costs and per-resource deltas.",
1049
- analyzePlanSchema.shape,
1050
- async (params) => {
1051
- const result = await analyzePlan(params, pricingEngine, config);
1052
- return {
1053
- content: [{ type: "text", text: JSON.stringify(result) }]
1054
- };
1055
- }
1271
+ analyzePlanSchema,
1272
+ (params) => analyzePlan(params, pricingEngine, config)
1056
1273
  );
1057
- server.tool(
1274
+ registerJsonTool(
1275
+ server,
1058
1276
  "compare_actual",
1059
1277
  "Parse a Terraform state file (.tfstate) and calculate actual infrastructure costs. Optionally compare against planned costs from HCL files to show drift.",
1060
- compareActualSchema.shape,
1061
- async (params) => {
1062
- const result = await compareActual(params, pricingEngine, config);
1063
- return {
1064
- content: [{ type: "text", text: JSON.stringify(result) }]
1065
- };
1066
- }
1278
+ compareActualSchema,
1279
+ (params) => compareActual(params, pricingEngine, config)
1067
1280
  );
1068
- server.tool(
1281
+ registerJsonTool(
1282
+ server,
1069
1283
  "price_trends",
1070
1284
  "Query historical pricing trends for a specific cloud resource. Returns recorded price history, the most recent price change, and summary metadata.",
1071
- priceTrendsSchema.shape,
1072
- (params) => {
1073
- const result = priceTrends(params, cache);
1074
- return {
1075
- content: [{ type: "text", text: JSON.stringify(result) }]
1076
- };
1077
- }
1285
+ priceTrendsSchema,
1286
+ (params) => priceTrends(params, cache)
1078
1287
  );
1079
- server.tool(
1288
+ registerJsonTool(
1289
+ server,
1080
1290
  "detect_anomalies",
1081
1291
  "Analyze infrastructure costs and flag anomalies \u2014 resources whose estimated costs are unusual compared to historical baselines or configured thresholds.",
1082
- detectAnomaliesSchema.shape,
1083
- async (params) => {
1084
- const result = await detectAnomalies(params, pricingEngine, cache, config);
1085
- return {
1086
- content: [{ type: "text", text: JSON.stringify(result) }]
1087
- };
1088
- }
1292
+ detectAnomaliesSchema,
1293
+ (params) => detectAnomalies(params, pricingEngine, cache, config)
1089
1294
  );
1295
+ registerJsonTool(
1296
+ server,
1297
+ "check_cost_budget",
1298
+ "Agent-ready cost guardrail. Returns allow / warn / block with blocking_resources so an AI agent can veto an expensive IaC write before committing. Thresholds cascade: per-call params > CLOUDCOST_GUARDRAIL_* env > CLOUDCOST_BUDGET_* env > no-op allow.",
1299
+ checkCostBudgetSchema,
1300
+ (params) => checkCostBudget(params, pricingEngine, config),
1301
+ // check_cost_budget returns structured errors (error: "provider_unresolved"
1302
+ // | "non_finite_total") instead of throwing; surface those as isError too.
1303
+ (result) => typeof result === "object" && result !== null && typeof result.error === "string"
1304
+ );
1305
+ return () => {
1306
+ cache.close();
1307
+ };
1090
1308
  }
1091
1309
 
1092
1310
  // src/server.ts
@@ -1099,26 +1317,54 @@ async function startServer() {
1099
1317
  name: "cloudcost-mcp",
1100
1318
  version
1101
1319
  });
1102
- registerTools(server, config);
1320
+ const closeTools = registerTools(server, config);
1103
1321
  const transport = new StdioServerTransport();
1104
1322
  logger.info("Starting CloudCost MCP server");
1105
1323
  await server.connect(transport);
1324
+ return async () => {
1325
+ logger.info("Shutting down CloudCost MCP server");
1326
+ await server.close();
1327
+ closeTools();
1328
+ };
1106
1329
  }
1107
1330
 
1108
1331
  // src/index.ts
1332
+ var shutdown = null;
1333
+ var exiting = false;
1334
+ async function closeAndExit(code) {
1335
+ if (exiting) return;
1336
+ exiting = true;
1337
+ try {
1338
+ await shutdown?.();
1339
+ } catch (err) {
1340
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
1341
+ process.stderr.write(`Error during shutdown: ${msg}
1342
+ `);
1343
+ }
1344
+ process.exit(code);
1345
+ }
1346
+ process.on("SIGINT", () => {
1347
+ void closeAndExit(0);
1348
+ });
1349
+ process.on("SIGTERM", () => {
1350
+ void closeAndExit(0);
1351
+ });
1109
1352
  process.on("uncaughtException", (err) => {
1110
1353
  process.stderr.write(`Uncaught exception: ${err.stack ?? err.message}
1111
1354
  `);
1112
- process.exit(1);
1355
+ void closeAndExit(1);
1113
1356
  });
1114
1357
  process.on("unhandledRejection", (reason) => {
1115
1358
  const msg = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
1116
1359
  process.stderr.write(`Unhandled rejection: ${msg}
1117
1360
  `);
1118
- process.exit(1);
1361
+ void closeAndExit(1);
1119
1362
  });
1120
- startServer().catch((err) => {
1121
- process.stderr.write(`Failed to start server: ${err.stack ?? err.message}
1363
+ startServer().then((stop) => {
1364
+ shutdown = stop;
1365
+ }).catch((err) => {
1366
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
1367
+ process.stderr.write(`Failed to start server: ${msg}
1122
1368
  `);
1123
1369
  process.exit(1);
1124
1370
  });