@jadenrazo/cloudcost-mcp 1.0.1 → 1.2.1

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,14 +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,
16
- fileContentSchema,
17
- filePathSchema,
18
+ getExchangeRateInfo,
19
+ iacFilesSchema,
18
20
  loadConfig,
19
21
  logger,
20
22
  mapInstance,
@@ -24,19 +26,22 @@ import {
24
26
  optimizeCostSchema,
25
27
  parseTerraform,
26
28
  planJsonSchema,
29
+ providerEnum,
27
30
  safeJsonParse,
28
31
  sanitizeForMessage,
29
32
  setLogLevel,
33
+ shortStringSchema,
30
34
  stateJsonSchema,
31
35
  str,
32
- tfvarsSchema,
36
+ tfvarsField,
33
37
  whatIf,
34
38
  whatIfSchema
35
- } from "./chunk-6O2Y6MKU.js";
39
+ } from "./chunk-CGMU4ETN.js";
36
40
  import {
37
41
  getFallbackMetadata,
42
+ getPricingMetadata,
38
43
  getResourceEquivalents
39
- } from "./chunk-MNFT5YKN.js";
44
+ } from "./chunk-LWTVE7UY.js";
40
45
 
41
46
  // src/server.ts
42
47
  import { createRequire } from "module";
@@ -82,7 +87,7 @@ var getEquivalentsSchema = z.object({
82
87
  "Instance type / VM size to also map across providers (e.g. t3.large, Standard_D4s_v3)"
83
88
  )
84
89
  });
85
- async function getEquivalents(params) {
90
+ function getEquivalents(params) {
86
91
  const sourceProvider = params.source_provider;
87
92
  let resourceEquivalents;
88
93
  if (params.target_provider) {
@@ -455,7 +460,8 @@ async function analyzePlan(params, pricingEngine, config) {
455
460
  total_delta_monthly: round2(totalAfter - totalBefore),
456
461
  summary: analysis.summary,
457
462
  resource_diffs: resourceDiffs,
458
- warnings: [...new Set(warnings)]
463
+ warnings: [...new Set(warnings)],
464
+ pricing_metadata: getPricingMetadata(targetProvider)
459
465
  };
460
466
  if (currency !== "USD") {
461
467
  result = applyResultCurrency(result, currency);
@@ -476,6 +482,7 @@ function applyResultCurrency(result, currency) {
476
482
  return {
477
483
  ...result,
478
484
  currency,
485
+ exchange_rate: getExchangeRateInfo(currency),
479
486
  total_before_monthly: round2(convertCurrency(result.total_before_monthly, currency)),
480
487
  total_after_monthly: round2(convertCurrency(result.total_after_monthly, currency)),
481
488
  total_delta_monthly: round2(convertCurrency(result.total_delta_monthly, currency)),
@@ -580,7 +587,7 @@ function extractTags2(attrs) {
580
587
  const result = {};
581
588
  for (const [k, v] of Object.entries(tags)) {
582
589
  if (typeof v === "string") result[k] = v;
583
- else if (v !== null && v !== void 0) result[k] = String(v);
590
+ else if (v !== null && v !== void 0) result[k] = coerceToString(v);
584
591
  }
585
592
  return result;
586
593
  }
@@ -680,18 +687,13 @@ function parseTerraformState(stateJson) {
680
687
  // src/tools/compare-actual.ts
681
688
  var compareActualSchema = z4.object({
682
689
  state_json: stateJsonSchema.describe("Contents of terraform.tfstate file (JSON)"),
683
- files: z4.array(
684
- z4.object({
685
- path: filePathSchema.describe("File path"),
686
- content: fileContentSchema.describe("File content (HCL)")
687
- })
688
- ).max(2e3, "files array exceeds 2000 entries").optional().describe("Optional Terraform files to compare planned costs against actual"),
689
- tfvars: tfvarsSchema.optional().describe("Contents of terraform.tfvars file"),
690
- provider: z4.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider override. Defaults to auto-detecting from the state."),
691
- region: z4.string().optional().describe(
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(
692
694
  "Target region override for pricing. Defaults to the region detected from the state."
693
695
  ),
694
- currency: z4.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
696
+ currency: currencyField
695
697
  });
696
698
  async function compareActual(params, pricingEngine, config) {
697
699
  const warnings = [];
@@ -722,7 +724,8 @@ async function compareActual(params, pricingEngine, config) {
722
724
  resource_count: stateInventory.total_count,
723
725
  by_resource: actualByResource
724
726
  },
725
- warnings: []
727
+ warnings: [],
728
+ pricing_metadata: getPricingMetadata(targetProvider)
726
729
  };
727
730
  if (params.files && params.files.length > 0) {
728
731
  const plannedInventory = await parseTerraform(params.files, params.tfvars);
@@ -756,6 +759,7 @@ async function compareActual(params, pricingEngine, config) {
756
759
  };
757
760
  }
758
761
  if (currency !== "USD") {
762
+ result.exchange_rate = getExchangeRateInfo(currency);
759
763
  const actualConverted = convertBreakdownCurrency(actualBreakdown, currency);
760
764
  result.actual_costs.total_monthly = Math.round(actualConverted.total_monthly * 100) / 100;
761
765
  result.actual_costs.total_yearly = Math.round(actualConverted.total_monthly * 12 * 100) / 100;
@@ -807,16 +811,11 @@ function priceTrends(params, cache) {
807
811
  // src/tools/detect-anomalies.ts
808
812
  import { z as z6 } from "zod";
809
813
  var detectAnomaliesSchema = z6.object({
810
- files: z6.array(
811
- z6.object({
812
- path: filePathSchema.describe("File path"),
813
- content: fileContentSchema.describe("File content")
814
- })
815
- ).max(2e3, "files array exceeds 2000 entries"),
816
- tfvars: tfvarsSchema.optional().describe("Variable overrides"),
817
- provider: z6.enum(["aws", "azure", "gcp"]).describe("Target cloud provider"),
818
- region: z6.string().optional().describe("Target region"),
819
- 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,
820
819
  budget_monthly: z6.number().optional().describe("Monthly budget threshold in USD"),
821
820
  budget_per_resource: z6.number().optional().describe("Per-resource cost threshold in USD"),
822
821
  price_change_threshold: z6.number().optional().default(10).describe("Alert on price changes exceeding this percentage")
@@ -882,12 +881,13 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
882
881
  }
883
882
  for (const est of byResource) {
884
883
  if (params.budget_per_resource != null && est.monthly_cost > params.budget_per_resource) {
884
+ const safeId = sanitizeForMessage(est.resource_id, 256);
885
885
  anomalies.push({
886
886
  type: "budget_exceeded",
887
887
  severity: "high",
888
- resource: est.resource_id,
889
- resource_type: est.resource_type,
890
- 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)}`,
891
891
  details: {
892
892
  current_cost: est.monthly_cost,
893
893
  threshold: params.budget_per_resource
@@ -903,19 +903,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
903
903
  const priceChange = cache.getPriceChange(targetProvider, service, resourceKey, targetRegion);
904
904
  if (priceChange && Math.abs(priceChange.change_percent) > priceChangeThreshold) {
905
905
  const direction = priceChange.change_percent > 0 ? "increased" : "decreased";
906
+ const safeId = sanitizeForMessage(est.resource_id, 256);
906
907
  anomalies.push({
907
908
  type: "price_change",
908
909
  severity: Math.abs(priceChange.change_percent) > 20 ? "high" : "medium",
909
- resource: est.resource_id,
910
- resource_type: est.resource_type,
911
- 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)}%`,
912
913
  details: {
913
914
  current_cost: est.monthly_cost,
914
915
  price_change_percent: priceChange.change_percent
915
916
  }
916
917
  });
917
918
  recommendations.push(
918
- `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.`
919
920
  );
920
921
  }
921
922
  }
@@ -923,19 +924,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
923
924
  for (const est of byResource) {
924
925
  const sharePercent = est.monthly_cost / totalMonthly * 100;
925
926
  if (sharePercent > 50 && byResource.length > 1) {
927
+ const safeId = sanitizeForMessage(est.resource_id, 256);
926
928
  anomalies.push({
927
929
  type: "cost_concentration",
928
930
  severity: "medium",
929
- resource: est.resource_id,
930
- resource_type: est.resource_type,
931
- 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)`,
932
934
  details: {
933
935
  current_cost: est.monthly_cost,
934
936
  cost_share_percent: sharePercent
935
937
  }
936
938
  });
937
939
  recommendations.push(
938
- `${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.`
939
941
  );
940
942
  }
941
943
  }
@@ -943,18 +945,20 @@ async function detectAnomalies(params, pricingEngine, cache, config) {
943
945
  for (const est of byResource) {
944
946
  const instanceType = resolveInstanceType(est);
945
947
  if (isOversizedInstance(instanceType)) {
948
+ const safeId = sanitizeForMessage(est.resource_id, 256);
949
+ const safeType = instanceType ? sanitizeForMessage(instanceType, 64) : "(unknown)";
946
950
  anomalies.push({
947
951
  type: "potential_oversizing",
948
952
  severity: "low",
949
- resource: est.resource_id,
950
- resource_type: est.resource_type,
951
- 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`,
952
956
  details: {
953
957
  current_cost: est.monthly_cost
954
958
  }
955
959
  });
956
960
  recommendations.push(
957
- `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.`
958
962
  );
959
963
  }
960
964
  }
@@ -984,131 +988,323 @@ function resolveInstanceType(est) {
984
988
  return void 0;
985
989
  }
986
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
+
987
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
+ }
988
1215
  function registerTools(server, config) {
989
1216
  const cache = new PricingCache(config.cache.db_path);
990
1217
  const pricingEngine = new PricingEngine(cache, config);
991
- server.tool(
1218
+ registerJsonTool(
1219
+ server,
992
1220
  "analyze_terraform",
993
1221
  "Parse Terraform HCL files and extract a resource inventory with provider detection, variable resolution, and cost-relevant attribute extraction",
994
- analyzeTerraformSchema.shape,
995
- async (params) => {
996
- const result = await analyzeTerraform(params);
997
- return {
998
- content: [{ type: "text", text: JSON.stringify(result) }]
999
- };
1000
- }
1222
+ analyzeTerraformSchema,
1223
+ (params) => analyzeTerraform(params)
1001
1224
  );
1002
- server.tool(
1225
+ registerJsonTool(
1226
+ server,
1003
1227
  "estimate_cost",
1004
1228
  "Estimate monthly and yearly cloud costs for Terraform resources on a specific provider. Returns a full cost breakdown by resource and service category.",
1005
- estimateCostSchema.shape,
1006
- async (params) => {
1007
- const result = await estimateCost(params, pricingEngine, config);
1008
- return {
1009
- content: [{ type: "text", text: JSON.stringify(result) }]
1010
- };
1011
- }
1229
+ estimateCostSchema,
1230
+ (params) => estimateCost(params, pricingEngine, config)
1012
1231
  );
1013
- server.tool(
1232
+ registerJsonTool(
1233
+ server,
1014
1234
  "compare_providers",
1015
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.",
1016
- compareProvidersSchema.shape,
1017
- async (params) => {
1018
- const result = await compareProviders(params, pricingEngine, config);
1019
- return {
1020
- content: [{ type: "text", text: JSON.stringify(result) }]
1021
- };
1022
- }
1236
+ compareProvidersSchema,
1237
+ (params) => compareProviders(params, pricingEngine, config)
1023
1238
  );
1024
- server.tool(
1239
+ registerJsonTool(
1240
+ server,
1025
1241
  "get_equivalents",
1026
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.",
1027
- getEquivalentsSchema.shape,
1028
- async (params) => {
1029
- const result = await getEquivalents(params);
1030
- return {
1031
- content: [{ type: "text", text: JSON.stringify(result) }]
1032
- };
1033
- }
1243
+ getEquivalentsSchema,
1244
+ (params) => getEquivalents(params)
1034
1245
  );
1035
- server.tool(
1246
+ registerJsonTool(
1247
+ server,
1036
1248
  "get_pricing",
1037
1249
  "Direct pricing lookup for a specific cloud provider, service, resource type, and region. Returns the normalised unit price with metadata.",
1038
- getPricingSchema.shape,
1039
- async (params) => {
1040
- const result = await getPricing(params, pricingEngine);
1041
- return {
1042
- content: [{ type: "text", text: JSON.stringify(result) }]
1043
- };
1044
- }
1250
+ getPricingSchema,
1251
+ (params) => getPricing(params, pricingEngine)
1045
1252
  );
1046
- server.tool(
1253
+ registerJsonTool(
1254
+ server,
1047
1255
  "optimize_cost",
1048
1256
  "Analyse Terraform resources and return cost optimisation recommendations including right-sizing suggestions, reserved pricing comparisons, and cross-provider savings opportunities.",
1049
- optimizeCostSchema.shape,
1050
- async (params) => {
1051
- const result = await optimizeCost(params, pricingEngine, config);
1052
- return {
1053
- content: [{ type: "text", text: JSON.stringify(result) }]
1054
- };
1055
- }
1257
+ optimizeCostSchema,
1258
+ (params) => optimizeCost(params, pricingEngine, config)
1056
1259
  );
1057
- server.tool(
1260
+ registerJsonTool(
1261
+ server,
1058
1262
  "what_if",
1059
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.",
1060
- whatIfSchema.shape,
1061
- async (params) => {
1062
- const result = await whatIf(params, pricingEngine, config);
1063
- return {
1064
- content: [{ type: "text", text: JSON.stringify(result) }]
1065
- };
1066
- }
1264
+ whatIfSchema,
1265
+ (params) => whatIf(params, pricingEngine, config)
1067
1266
  );
1068
- server.tool(
1267
+ registerJsonTool(
1268
+ server,
1069
1269
  "analyze_plan",
1070
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.",
1071
- analyzePlanSchema.shape,
1072
- async (params) => {
1073
- const result = await analyzePlan(params, pricingEngine, config);
1074
- return {
1075
- content: [{ type: "text", text: JSON.stringify(result) }]
1076
- };
1077
- }
1271
+ analyzePlanSchema,
1272
+ (params) => analyzePlan(params, pricingEngine, config)
1078
1273
  );
1079
- server.tool(
1274
+ registerJsonTool(
1275
+ server,
1080
1276
  "compare_actual",
1081
1277
  "Parse a Terraform state file (.tfstate) and calculate actual infrastructure costs. Optionally compare against planned costs from HCL files to show drift.",
1082
- compareActualSchema.shape,
1083
- async (params) => {
1084
- const result = await compareActual(params, pricingEngine, config);
1085
- return {
1086
- content: [{ type: "text", text: JSON.stringify(result) }]
1087
- };
1088
- }
1278
+ compareActualSchema,
1279
+ (params) => compareActual(params, pricingEngine, config)
1089
1280
  );
1090
- server.tool(
1281
+ registerJsonTool(
1282
+ server,
1091
1283
  "price_trends",
1092
1284
  "Query historical pricing trends for a specific cloud resource. Returns recorded price history, the most recent price change, and summary metadata.",
1093
- priceTrendsSchema.shape,
1094
- (params) => {
1095
- const result = priceTrends(params, cache);
1096
- return {
1097
- content: [{ type: "text", text: JSON.stringify(result) }]
1098
- };
1099
- }
1285
+ priceTrendsSchema,
1286
+ (params) => priceTrends(params, cache)
1100
1287
  );
1101
- server.tool(
1288
+ registerJsonTool(
1289
+ server,
1102
1290
  "detect_anomalies",
1103
1291
  "Analyze infrastructure costs and flag anomalies \u2014 resources whose estimated costs are unusual compared to historical baselines or configured thresholds.",
1104
- detectAnomaliesSchema.shape,
1105
- async (params) => {
1106
- const result = await detectAnomalies(params, pricingEngine, cache, config);
1107
- return {
1108
- content: [{ type: "text", text: JSON.stringify(result) }]
1109
- };
1110
- }
1292
+ detectAnomaliesSchema,
1293
+ (params) => detectAnomalies(params, pricingEngine, cache, config)
1111
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
+ };
1112
1308
  }
1113
1309
 
1114
1310
  // src/server.ts
@@ -1121,26 +1317,54 @@ async function startServer() {
1121
1317
  name: "cloudcost-mcp",
1122
1318
  version
1123
1319
  });
1124
- registerTools(server, config);
1320
+ const closeTools = registerTools(server, config);
1125
1321
  const transport = new StdioServerTransport();
1126
1322
  logger.info("Starting CloudCost MCP server");
1127
1323
  await server.connect(transport);
1324
+ return async () => {
1325
+ logger.info("Shutting down CloudCost MCP server");
1326
+ await server.close();
1327
+ closeTools();
1328
+ };
1128
1329
  }
1129
1330
 
1130
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
+ });
1131
1352
  process.on("uncaughtException", (err) => {
1132
1353
  process.stderr.write(`Uncaught exception: ${err.stack ?? err.message}
1133
1354
  `);
1134
- process.exit(1);
1355
+ void closeAndExit(1);
1135
1356
  });
1136
1357
  process.on("unhandledRejection", (reason) => {
1137
1358
  const msg = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
1138
1359
  process.stderr.write(`Unhandled rejection: ${msg}
1139
1360
  `);
1140
- process.exit(1);
1361
+ void closeAndExit(1);
1141
1362
  });
1142
- startServer().catch((err) => {
1143
- 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}
1144
1368
  `);
1145
1369
  process.exit(1);
1146
1370
  });