@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/CHANGELOG.md +56 -5
- package/README.md +70 -115
- package/VERSIONING.md +99 -0
- package/data/aws-pricing/metadata.json +8 -0
- package/data/azure-pricing/metadata.json +8 -0
- package/data/gcp-pricing/metadata.json +4 -2
- package/dist/{chunk-E7KOWAMW.js → chunk-CGMU4ETN.js} +2010 -1209
- package/dist/{chunk-TRRAOOVF.js → chunk-LWTVE7UY.js} +31 -1
- package/dist/cli.d.ts +41 -0
- package/dist/cli.js +63 -22
- package/dist/index.js +391 -145
- package/dist/{loader-VXYJYDIH.js → loader-CMBV7346.js} +6 -2
- package/package.json +34 -21
- package/MIGRATION.md +0 -40
- package/STABILITY.md +0 -71
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-
|
|
39
|
+
} from "./chunk-CGMU4ETN.js";
|
|
29
40
|
import {
|
|
41
|
+
getFallbackMetadata,
|
|
42
|
+
getPricingMetadata,
|
|
30
43
|
getResourceEquivalents
|
|
31
|
-
} from "./chunk-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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] =
|
|
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 =
|
|
598
|
+
state = safeJsonParse(stateJson);
|
|
571
599
|
} catch (err) {
|
|
572
600
|
const msg = err instanceof Error ? err.message : String(err);
|
|
573
|
-
|
|
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: ${
|
|
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:
|
|
661
|
-
files:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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:
|
|
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:
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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:
|
|
867
|
-
resource_type: est.resource_type,
|
|
868
|
-
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)}`,
|
|
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:
|
|
888
|
-
resource_type: est.resource_type,
|
|
889
|
-
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)}%`,
|
|
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 ${
|
|
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:
|
|
908
|
-
resource_type: est.resource_type,
|
|
909
|
-
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)`,
|
|
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
|
-
`${
|
|
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:
|
|
928
|
-
resource_type: est.resource_type,
|
|
929
|
-
message: `Instance ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
973
|
-
|
|
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
|
-
|
|
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
|
|
984
|
-
|
|
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
|
-
|
|
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
|
|
995
|
-
|
|
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
|
-
|
|
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
|
|
1006
|
-
|
|
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
|
-
|
|
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
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
|
1039
|
-
|
|
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
|
-
|
|
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
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
|
1061
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1083
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1361
|
+
void closeAndExit(1);
|
|
1119
1362
|
});
|
|
1120
|
-
startServer().
|
|
1121
|
-
|
|
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
|
});
|