@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/CHANGELOG.md +62 -5
- package/README.md +70 -115
- package/VERSIONING.md +99 -0
- package/data/aws-pricing/metadata.json +1 -1
- package/data/azure-pricing/metadata.json +1 -1
- package/dist/{chunk-6O2Y6MKU.js → chunk-CGMU4ETN.js} +927 -1067
- package/dist/{chunk-MNFT5YKN.js → chunk-LWTVE7UY.js} +19 -1
- package/dist/cli.d.ts +46 -1
- package/dist/cli.js +63 -23
- package/dist/index.js +361 -137
- package/dist/{loader-UWVEXYMR.js → loader-CMBV7346.js} +4 -2
- package/package.json +34 -21
- package/MIGRATION.md +0 -40
- package/STABILITY.md +0 -71
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
36
|
+
tfvarsField,
|
|
33
37
|
whatIf,
|
|
34
38
|
whatIfSchema
|
|
35
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-CGMU4ETN.js";
|
|
36
40
|
import {
|
|
37
41
|
getFallbackMetadata,
|
|
42
|
+
getPricingMetadata,
|
|
38
43
|
getResourceEquivalents
|
|
39
|
-
} from "./chunk-
|
|
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
|
-
|
|
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] =
|
|
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:
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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:
|
|
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:
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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:
|
|
889
|
-
resource_type: est.resource_type,
|
|
890
|
-
message: `Resource ${
|
|
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:
|
|
910
|
-
resource_type: est.resource_type,
|
|
911
|
-
message: `Pricing for ${
|
|
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 ${
|
|
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:
|
|
930
|
-
resource_type: est.resource_type,
|
|
931
|
-
message: `Resource ${
|
|
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
|
-
`${
|
|
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:
|
|
950
|
-
resource_type: est.resource_type,
|
|
951
|
-
message: `Instance ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
995
|
-
|
|
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
|
-
|
|
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
|
|
1006
|
-
|
|
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
|
-
|
|
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
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
|
1039
|
-
|
|
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
|
-
|
|
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
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
|
1061
|
-
|
|
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
|
-
|
|
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
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
|
1083
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1361
|
+
void closeAndExit(1);
|
|
1141
1362
|
});
|
|
1142
|
-
startServer().
|
|
1143
|
-
|
|
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
|
});
|