@jadenrazo/cloudcost-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,25 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ CostEngine,
3
4
  PricingCache,
4
5
  PricingEngine,
6
+ SUPPORTED_CURRENCIES,
5
7
  analyzeTerraform,
6
8
  analyzeTerraformSchema,
9
+ bool,
7
10
  compareProviders,
8
11
  compareProvidersSchema,
12
+ convertBreakdownCurrency,
13
+ convertCurrency,
9
14
  estimateCost,
10
15
  estimateCostSchema,
11
16
  loadConfig,
12
17
  logger,
13
18
  mapInstance,
19
+ mapRegion,
20
+ num,
14
21
  optimizeCost,
15
22
  optimizeCostSchema,
23
+ parseTerraform,
16
24
  setLogLevel,
25
+ str,
17
26
  whatIf,
18
27
  whatIfSchema
19
- } from "./chunk-EI63JCAA.js";
28
+ } from "./chunk-VP34WG7Z.js";
20
29
  import {
21
30
  getResourceEquivalents
22
- } from "./chunk-XDBTEI42.js";
31
+ } from "./chunk-TRRAOOVF.js";
23
32
 
24
33
  // src/server.ts
25
34
  import { createRequire } from "module";
@@ -35,17 +44,13 @@ function findEquivalent(resourceType, sourceProvider, targetProvider) {
35
44
  return resourceType;
36
45
  }
37
46
  const equivalents = getResourceEquivalents();
38
- const row = equivalents.find(
39
- (entry) => entry[sourceProvider] === resourceType
40
- );
47
+ const row = equivalents.find((entry) => entry[sourceProvider] === resourceType);
41
48
  if (!row) return null;
42
49
  return row[targetProvider] ?? null;
43
50
  }
44
51
  function findAllEquivalents(resourceType, sourceProvider) {
45
52
  const equivalents = getResourceEquivalents();
46
- const row = equivalents.find(
47
- (entry) => entry[sourceProvider] === resourceType
48
- );
53
+ const row = equivalents.find((entry) => entry[sourceProvider] === resourceType);
49
54
  if (!row) return {};
50
55
  const result = {};
51
56
  const providers = ["aws", "azure", "gcp"];
@@ -60,9 +65,7 @@ function findAllEquivalents(resourceType, sourceProvider) {
60
65
 
61
66
  // src/tools/get-equivalents.ts
62
67
  var getEquivalentsSchema = z.object({
63
- resource_type: z.string().describe(
64
- "Terraform resource type to look up (e.g. aws_instance, google_compute_instance)"
65
- ),
68
+ resource_type: z.string().describe("Terraform resource type to look up (e.g. aws_instance, google_compute_instance)"),
66
69
  source_provider: z.enum(["aws", "azure", "gcp"]).describe("Cloud provider the resource type belongs to"),
67
70
  target_provider: z.enum(["aws", "azure", "gcp"]).optional().describe(
68
71
  "Specific target provider. When omitted, equivalents for all providers are returned."
@@ -76,11 +79,7 @@ async function getEquivalents(params) {
76
79
  let resourceEquivalents;
77
80
  if (params.target_provider) {
78
81
  const targetProvider = params.target_provider;
79
- const equivalent = findEquivalent(
80
- params.resource_type,
81
- sourceProvider,
82
- targetProvider
83
- );
82
+ const equivalent = findEquivalent(params.resource_type, sourceProvider, targetProvider);
84
83
  resourceEquivalents = { [targetProvider]: equivalent };
85
84
  } else {
86
85
  resourceEquivalents = findAllEquivalents(params.resource_type, sourceProvider);
@@ -91,29 +90,38 @@ async function getEquivalents(params) {
91
90
  const targets = params.target_provider ? [params.target_provider] : providers.filter((p) => p !== sourceProvider);
92
91
  instanceEquivalents = {};
93
92
  for (const target of targets) {
94
- instanceEquivalents[target] = mapInstance(
95
- params.instance_type,
96
- sourceProvider,
97
- target
98
- );
93
+ instanceEquivalents[target] = mapInstance(params.instance_type, sourceProvider, target);
99
94
  }
100
95
  }
101
- return {
102
- resource_type: params.resource_type,
103
- source_provider: sourceProvider,
104
- resource_equivalents: resourceEquivalents,
105
- ...instanceEquivalents !== void 0 ? {
106
- instance_type: params.instance_type,
107
- instance_equivalents: instanceEquivalents
108
- } : {}
96
+ const filteredResourceEquivalents = Object.fromEntries(
97
+ Object.entries(resourceEquivalents).filter(([, v]) => v !== null)
98
+ );
99
+ const result = {
100
+ resource_equivalents: filteredResourceEquivalents
109
101
  };
102
+ if (instanceEquivalents !== void 0) {
103
+ const filteredInstanceEquivalents = Object.fromEntries(
104
+ Object.entries(instanceEquivalents).filter(([, v]) => v !== null)
105
+ );
106
+ result.instance_type = params.instance_type;
107
+ result.instance_equivalents = filteredInstanceEquivalents;
108
+ }
109
+ return result;
110
110
  }
111
111
 
112
112
  // src/tools/get-pricing.ts
113
113
  import { z as z2 } from "zod";
114
114
  var getPricingSchema = z2.object({
115
115
  provider: z2.enum(["aws", "azure", "gcp"]).describe("Cloud provider to look up pricing for"),
116
- service: z2.enum(["compute", "database", "storage", "network", "load_balancer", "nat_gateway", "kubernetes"]).describe("Service category (network defaults to nat_gateway for backward compatibility)"),
116
+ service: z2.enum([
117
+ "compute",
118
+ "database",
119
+ "storage",
120
+ "network",
121
+ "load_balancer",
122
+ "nat_gateway",
123
+ "kubernetes"
124
+ ]).describe("Service category (network defaults to nat_gateway for backward compatibility)"),
117
125
  resource_type: z2.string().describe(
118
126
  "Instance type, storage type, or resource identifier (e.g. t3.large, gp3, Standard_D4s_v3)"
119
127
  ),
@@ -131,20 +139,828 @@ async function getPricing(params, pricingEngine) {
131
139
  kubernetes: "kubernetes"
132
140
  };
133
141
  const service = serviceMap[params.service] ?? params.service;
134
- const price = await pricingEngine.getPrice(
142
+ const rawPrice = await pricingEngine.getPrice(
135
143
  provider,
136
144
  service,
137
145
  params.resource_type,
138
146
  params.region
139
147
  );
148
+ let price = null;
149
+ if (rawPrice !== null) {
150
+ const {
151
+ description: _d,
152
+ attributes,
153
+ ...priceRest
154
+ } = rawPrice;
155
+ price = { ...priceRest };
156
+ if (attributes && Object.keys(attributes).length > 0) {
157
+ price.attributes = attributes;
158
+ }
159
+ }
160
+ return { price };
161
+ }
162
+
163
+ // src/tools/analyze-plan.ts
164
+ import { z as z3 } from "zod";
165
+
166
+ // src/parsers/terraform/plan-parser.ts
167
+ var PROVIDER_MAP = {
168
+ aws: "aws",
169
+ azurerm: "azure",
170
+ google: "gcp"
171
+ };
172
+ function detectProviderFromName(providerName) {
173
+ const segments = providerName.split("/");
174
+ const shortName = segments[segments.length - 1];
175
+ return PROVIDER_MAP[shortName] ?? "aws";
176
+ }
177
+ var DEFAULT_REGIONS = {
178
+ aws: "us-east-1",
179
+ azure: "eastus",
180
+ gcp: "us-central1"
181
+ };
182
+ function detectRegion(attrs, provider) {
183
+ if (!attrs) return DEFAULT_REGIONS[provider];
184
+ if (provider === "aws") {
185
+ const az = typeof attrs["availability_zone"] === "string" ? attrs["availability_zone"] : null;
186
+ if (az) {
187
+ return az.replace(/[a-z]$/, "");
188
+ }
189
+ const region = typeof attrs["region"] === "string" ? attrs["region"] : null;
190
+ if (region) return region;
191
+ }
192
+ if (provider === "azure") {
193
+ const loc = typeof attrs["location"] === "string" ? attrs["location"] : null;
194
+ if (loc) return loc;
195
+ }
196
+ if (provider === "gcp") {
197
+ const zone = typeof attrs["zone"] === "string" ? attrs["zone"] : null;
198
+ if (zone) {
199
+ const parts = zone.split("-");
200
+ return parts.length > 2 ? parts.slice(0, -1).join("-") : zone;
201
+ }
202
+ const region = typeof attrs["region"] === "string" ? attrs["region"] : null;
203
+ if (region) return region;
204
+ }
205
+ return DEFAULT_REGIONS[provider];
206
+ }
207
+ var ATTRIBUTE_KEYS = [
208
+ "instance_type",
209
+ "vm_size",
210
+ "machine_type",
211
+ "engine",
212
+ "engine_version",
213
+ "storage_type",
214
+ "iops",
215
+ "throughput_mbps",
216
+ "multi_az",
217
+ "replicas",
218
+ "node_count",
219
+ "min_node_count",
220
+ "max_node_count",
221
+ "os",
222
+ "tier",
223
+ "sku"
224
+ ];
225
+ var STORAGE_SIZE_KEYS = ["size", "storage_size_gb", "disk_size_gb", "max_size_gb"];
226
+ function extractAttributes(attrs) {
227
+ const result = {};
228
+ for (const key of ATTRIBUTE_KEYS) {
229
+ if (attrs[key] !== void 0 && attrs[key] !== null) {
230
+ result[key] = attrs[key];
231
+ }
232
+ }
233
+ for (const key of STORAGE_SIZE_KEYS) {
234
+ const val = attrs[key];
235
+ if (typeof val === "number" && val > 0) {
236
+ result.storage_size_gb = val;
237
+ break;
238
+ }
239
+ }
240
+ return result;
241
+ }
242
+ function extractTags(attrs) {
243
+ const tags = attrs["tags"];
244
+ if (!tags || typeof tags !== "object" || Array.isArray(tags)) return {};
245
+ const result = {};
246
+ for (const [k, v] of Object.entries(tags)) {
247
+ result[k] = String(v);
248
+ }
249
+ return result;
250
+ }
251
+ function buildResource(rc, attrs, provider, region) {
252
+ return {
253
+ id: rc.address,
254
+ type: rc.type,
255
+ name: rc.name,
256
+ provider,
257
+ region,
258
+ attributes: extractAttributes(attrs),
259
+ tags: extractTags(attrs),
260
+ source_file: "plan.json"
261
+ };
262
+ }
263
+ function classifyAction(actions) {
264
+ if (actions.length === 2) {
265
+ if (actions.includes("create") && actions.includes("delete")) {
266
+ return "replace";
267
+ }
268
+ }
269
+ if (actions.length === 1) {
270
+ const a = actions[0];
271
+ if (a === "create") return "create";
272
+ if (a === "delete") return "delete";
273
+ if (a === "update") return "update";
274
+ if (a === "no-op") return "no-op";
275
+ if (a === "read") return "no-op";
276
+ }
277
+ return "no-op";
278
+ }
279
+ function buildInventory(resources, warnings) {
280
+ const byType = {};
281
+ for (const r of resources) {
282
+ byType[r.type] = (byType[r.type] ?? 0) + 1;
283
+ }
284
+ const providerCounts = {};
285
+ for (const r of resources) {
286
+ providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
287
+ }
288
+ let dominant = "aws";
289
+ let max = 0;
290
+ for (const [p, c] of Object.entries(providerCounts)) {
291
+ if (c > max) {
292
+ max = c;
293
+ dominant = p;
294
+ }
295
+ }
296
+ const dominantResource = resources.find((r) => r.provider === dominant);
297
+ const region = dominantResource?.region ?? DEFAULT_REGIONS[dominant];
298
+ return {
299
+ provider: dominant,
300
+ region,
301
+ resources,
302
+ total_count: resources.length,
303
+ by_type: byType,
304
+ parse_warnings: warnings
305
+ };
306
+ }
307
+ function parseTerraformPlan(planJson) {
308
+ let plan;
309
+ try {
310
+ plan = JSON.parse(planJson);
311
+ } catch (err) {
312
+ const msg = err instanceof Error ? err.message : String(err);
313
+ throw new Error(`Invalid plan JSON: ${msg}`);
314
+ }
315
+ if (!Array.isArray(plan.resource_changes)) {
316
+ throw new Error("Invalid plan JSON: missing or non-array resource_changes field");
317
+ }
318
+ const beforeResources = [];
319
+ const afterResources = [];
320
+ const changes = [];
321
+ const warnings = [];
322
+ const summary = {
323
+ creates: 0,
324
+ updates: 0,
325
+ deletes: 0,
326
+ no_ops: 0,
327
+ replaces: 0
328
+ };
329
+ for (const rc of plan.resource_changes) {
330
+ const provider = detectProviderFromName(rc.provider_name);
331
+ const action = classifyAction(rc.change.actions);
332
+ const region = detectRegion(rc.change.after, provider) || detectRegion(rc.change.before, provider);
333
+ if (rc.change.before) {
334
+ beforeResources.push(buildResource(rc, rc.change.before, provider, region));
335
+ }
336
+ if (rc.change.after) {
337
+ afterResources.push(buildResource(rc, rc.change.after, provider, region));
338
+ }
339
+ changes.push({
340
+ address: rc.address,
341
+ type: rc.type,
342
+ action,
343
+ before_attributes: rc.change.before,
344
+ after_attributes: rc.change.after
345
+ });
346
+ switch (action) {
347
+ case "create":
348
+ summary.creates++;
349
+ break;
350
+ case "update":
351
+ summary.updates++;
352
+ break;
353
+ case "delete":
354
+ summary.deletes++;
355
+ break;
356
+ case "replace":
357
+ summary.replaces++;
358
+ break;
359
+ case "no-op":
360
+ summary.no_ops++;
361
+ break;
362
+ }
363
+ }
364
+ logger.debug("parseTerraformPlan complete", {
365
+ resourceChanges: plan.resource_changes.length,
366
+ summary
367
+ });
368
+ return {
369
+ before: buildInventory(beforeResources, warnings),
370
+ after: buildInventory(afterResources, warnings),
371
+ changes,
372
+ summary
373
+ };
374
+ }
375
+
376
+ // src/tools/analyze-plan.ts
377
+ var analyzePlanSchema = z3.object({
378
+ plan_json: z3.string().describe("Output of 'terraform show -json <planfile>' or 'terraform plan -json'"),
379
+ provider: z3.enum(["aws", "azure", "gcp"]).optional().describe("Target provider for cost estimation. If omitted, auto-detected from plan."),
380
+ region: z3.string().optional().describe("Target region. If omitted, detected from plan resources."),
381
+ currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency")
382
+ });
383
+ async function analyzePlan(params, pricingEngine, config) {
384
+ const analysis = parseTerraformPlan(params.plan_json);
385
+ const targetProvider = params.provider ?? analysis.after.provider;
386
+ const targetRegion = params.region ?? mapRegion(analysis.after.region, analysis.after.provider, targetProvider);
387
+ const costEngine = new CostEngine(pricingEngine, config);
388
+ const [beforeBreakdown, afterBreakdown] = await Promise.all([
389
+ analysis.before.resources.length > 0 ? costEngine.calculateBreakdown(analysis.before.resources, targetProvider, targetRegion) : null,
390
+ analysis.after.resources.length > 0 ? costEngine.calculateBreakdown(analysis.after.resources, targetProvider, targetRegion) : null
391
+ ]);
392
+ const currency = params.currency ?? "USD";
393
+ const warnings = [
394
+ ...analysis.before.parse_warnings,
395
+ ...analysis.after.parse_warnings,
396
+ ...beforeBreakdown?.warnings ?? [],
397
+ ...afterBreakdown?.warnings ?? []
398
+ ];
399
+ const beforeCostMap = /* @__PURE__ */ new Map();
400
+ if (beforeBreakdown) {
401
+ for (const est of beforeBreakdown.by_resource) {
402
+ beforeCostMap.set(est.resource_id, est.monthly_cost);
403
+ }
404
+ }
405
+ const afterCostMap = /* @__PURE__ */ new Map();
406
+ if (afterBreakdown) {
407
+ for (const est of afterBreakdown.by_resource) {
408
+ afterCostMap.set(est.resource_id, est.monthly_cost);
409
+ }
410
+ }
411
+ const resourceDiffs = [];
412
+ for (const change of analysis.changes) {
413
+ const beforeMonthly = beforeCostMap.get(change.address) ?? 0;
414
+ const afterMonthly = afterCostMap.get(change.address) ?? 0;
415
+ const delta = afterMonthly - beforeMonthly;
416
+ resourceDiffs.push({
417
+ address: change.address,
418
+ resource_type: change.type,
419
+ action: change.action,
420
+ before_monthly: round2(beforeMonthly),
421
+ after_monthly: round2(afterMonthly),
422
+ delta_monthly: round2(delta),
423
+ currency: "USD"
424
+ });
425
+ }
426
+ const totalBefore = beforeBreakdown?.total_monthly ?? 0;
427
+ const totalAfter = afterBreakdown?.total_monthly ?? 0;
428
+ let result = {
429
+ provider: targetProvider,
430
+ region: targetRegion,
431
+ currency: "USD",
432
+ total_before_monthly: round2(totalBefore),
433
+ total_after_monthly: round2(totalAfter),
434
+ total_delta_monthly: round2(totalAfter - totalBefore),
435
+ summary: analysis.summary,
436
+ resource_diffs: resourceDiffs,
437
+ warnings: [...new Set(warnings)]
438
+ };
439
+ if (currency !== "USD") {
440
+ result = applyResultCurrency(result, currency);
441
+ }
442
+ logger.debug("analyzePlan complete", {
443
+ provider: result.provider,
444
+ region: result.region,
445
+ totalBefore: result.total_before_monthly,
446
+ totalAfter: result.total_after_monthly,
447
+ delta: result.total_delta_monthly
448
+ });
449
+ return result;
450
+ }
451
+ function round2(n) {
452
+ return Math.round(n * 100) / 100;
453
+ }
454
+ function applyResultCurrency(result, currency) {
455
+ return {
456
+ ...result,
457
+ currency,
458
+ total_before_monthly: round2(convertCurrency(result.total_before_monthly, currency)),
459
+ total_after_monthly: round2(convertCurrency(result.total_after_monthly, currency)),
460
+ total_delta_monthly: round2(convertCurrency(result.total_delta_monthly, currency)),
461
+ resource_diffs: result.resource_diffs.map((d) => ({
462
+ ...d,
463
+ currency,
464
+ before_monthly: round2(convertCurrency(d.before_monthly, currency)),
465
+ after_monthly: round2(convertCurrency(d.after_monthly, currency)),
466
+ delta_monthly: round2(convertCurrency(d.delta_monthly, currency))
467
+ }))
468
+ };
469
+ }
470
+
471
+ // src/tools/compare-actual.ts
472
+ import { z as z4 } from "zod";
473
+
474
+ // src/parsers/terraform/state-parser.ts
475
+ var PROVIDER_MAP2 = {
476
+ aws: "aws",
477
+ azurerm: "azure",
478
+ google: "gcp",
479
+ "google-beta": "gcp"
480
+ };
481
+ function detectProviderFromString(providerAddr) {
482
+ const match = providerAddr.match(/\/([^"/]+)"\s*\]?\s*$/);
483
+ if (match) {
484
+ return PROVIDER_MAP2[match[1]];
485
+ }
486
+ for (const [key, value] of Object.entries(PROVIDER_MAP2)) {
487
+ if (providerAddr.includes(`/${key}"`) || providerAddr.includes(`/${key}]`)) {
488
+ return value;
489
+ }
490
+ }
491
+ return void 0;
492
+ }
493
+ var PROVIDER_DEFAULTS = {
494
+ aws: "us-east-1",
495
+ azure: "eastus",
496
+ gcp: "us-central1"
497
+ };
498
+ function detectRegion2(attrs, provider) {
499
+ switch (provider) {
500
+ case "aws": {
501
+ const az = str(attrs["availability_zone"]);
502
+ if (az) return az.replace(/[a-z]$/, "");
503
+ const region = str(attrs["region"]);
504
+ if (region) return region;
505
+ return PROVIDER_DEFAULTS.aws;
506
+ }
507
+ case "azure": {
508
+ const location = str(attrs["location"]);
509
+ if (location) return location;
510
+ return PROVIDER_DEFAULTS.azure;
511
+ }
512
+ case "gcp": {
513
+ const zone = str(attrs["zone"]);
514
+ if (zone) {
515
+ const parts = zone.split("-");
516
+ return parts.length > 2 ? parts.slice(0, -1).join("-") : zone;
517
+ }
518
+ const region = str(attrs["region"]);
519
+ if (region) return region;
520
+ return PROVIDER_DEFAULTS.gcp;
521
+ }
522
+ }
523
+ }
524
+ function extractAttributes2(attrs, resourceType) {
525
+ const result = {};
526
+ if (str(attrs["instance_type"])) result.instance_type = str(attrs["instance_type"]);
527
+ if (str(attrs["instance_class"])) result.instance_type = str(attrs["instance_class"]);
528
+ if (str(attrs["vm_size"])) result.vm_size = str(attrs["vm_size"]);
529
+ if (str(attrs["machine_type"])) result.machine_type = str(attrs["machine_type"]);
530
+ if (str(attrs["engine"])) result.engine = str(attrs["engine"]);
531
+ if (str(attrs["engine_version"])) result.engine_version = str(attrs["engine_version"]);
532
+ if (attrs["multi_az"] !== void 0) result.multi_az = bool(attrs["multi_az"]);
533
+ if (attrs["replicas"] !== void 0) result.replicas = num(attrs["replicas"]);
534
+ if (str(attrs["storage_type"])) result.storage_type = str(attrs["storage_type"]);
535
+ if (attrs["allocated_storage"] !== void 0)
536
+ result.storage_size_gb = num(attrs["allocated_storage"]);
537
+ if (attrs["size"] !== void 0 && result.storage_size_gb === void 0)
538
+ result.storage_size_gb = num(attrs["size"]);
539
+ if (attrs["iops"] !== void 0) result.iops = num(attrs["iops"]);
540
+ if (attrs["throughput"] !== void 0) result.throughput_mbps = num(attrs["throughput"]);
541
+ if (str(attrs["tier"])) result.tier = str(attrs["tier"]);
542
+ if (str(attrs["sku"])) result.sku = str(attrs["sku"]);
543
+ if (str(attrs["sku_name"])) result.sku = str(attrs["sku_name"]);
544
+ if (attrs["node_count"] !== void 0) result.node_count = num(attrs["node_count"]);
545
+ if (attrs["min_node_count"] !== void 0) result.min_node_count = num(attrs["min_node_count"]);
546
+ if (attrs["max_node_count"] !== void 0) result.max_node_count = num(attrs["max_node_count"]);
547
+ if (str(attrs["os_type"])) result.os = str(attrs["os_type"]);
548
+ if (resourceType === "aws_ebs_volume" || resourceType === "aws_ebs_snapshot") {
549
+ if (str(attrs["type"]) && !result.storage_type) result.storage_type = str(attrs["type"]);
550
+ }
551
+ if (str(attrs["load_balancer_type"]))
552
+ result.load_balancer_type = str(attrs["load_balancer_type"]);
553
+ if (attrs["internal"] !== void 0) result.internal = bool(attrs["internal"]);
554
+ return result;
555
+ }
556
+ function extractTags2(attrs) {
557
+ const tags = attrs["tags"];
558
+ if (!tags || typeof tags !== "object" || Array.isArray(tags)) return {};
559
+ const result = {};
560
+ for (const [k, v] of Object.entries(tags)) {
561
+ if (typeof v === "string") result[k] = v;
562
+ else if (v !== null && v !== void 0) result[k] = String(v);
563
+ }
564
+ return result;
565
+ }
566
+ function parseTerraformState(stateJson) {
567
+ const warnings = [];
568
+ let state;
569
+ try {
570
+ state = JSON.parse(stateJson);
571
+ } catch (err) {
572
+ const msg = err instanceof Error ? err.message : String(err);
573
+ logger.error("Failed to parse Terraform state JSON", { error: msg });
574
+ return {
575
+ provider: "aws",
576
+ region: PROVIDER_DEFAULTS.aws,
577
+ resources: [],
578
+ total_count: 0,
579
+ by_type: {},
580
+ parse_warnings: [`Invalid state JSON: ${msg}`]
581
+ };
582
+ }
583
+ if (!Array.isArray(state.resources)) {
584
+ return {
585
+ provider: "aws",
586
+ region: PROVIDER_DEFAULTS.aws,
587
+ resources: [],
588
+ total_count: 0,
589
+ by_type: {},
590
+ parse_warnings: warnings
591
+ };
592
+ }
593
+ const allResources = [];
594
+ for (const block of state.resources) {
595
+ if (block.mode !== "managed") continue;
596
+ const provider = detectProviderFromString(block.provider);
597
+ if (!provider) {
598
+ warnings.push(
599
+ `Skipping resource "${block.type}.${block.name}": unknown provider "${block.provider}"`
600
+ );
601
+ continue;
602
+ }
603
+ if (!Array.isArray(block.instances)) continue;
604
+ for (const instance of block.instances) {
605
+ const attrs = instance.attributes ?? {};
606
+ const region = detectRegion2(attrs, provider);
607
+ const tags = extractTags2(attrs);
608
+ const attributes = extractAttributes2(attrs, block.type);
609
+ let id = `${block.type}.${block.name}`;
610
+ if (instance.index_key !== void 0) {
611
+ id += typeof instance.index_key === "number" ? `[${instance.index_key}]` : `["${instance.index_key}"]`;
612
+ }
613
+ allResources.push({
614
+ id,
615
+ type: block.type,
616
+ name: block.name,
617
+ provider,
618
+ region,
619
+ attributes,
620
+ tags,
621
+ source_file: "terraform.tfstate"
622
+ });
623
+ }
624
+ }
625
+ const providerCounts = {};
626
+ for (const r of allResources) {
627
+ providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
628
+ }
629
+ let dominantProvider = "aws";
630
+ let maxCount = 0;
631
+ for (const [p, c] of Object.entries(providerCounts)) {
632
+ if (c > maxCount) {
633
+ maxCount = c;
634
+ dominantProvider = p;
635
+ }
636
+ }
637
+ const dominantRegion = allResources.find((r) => r.provider === dominantProvider)?.region ?? PROVIDER_DEFAULTS[dominantProvider];
638
+ const byType = {};
639
+ for (const r of allResources) {
640
+ byType[r.type] = (byType[r.type] ?? 0) + 1;
641
+ }
642
+ logger.info("Terraform state parse complete", {
643
+ resourceCount: allResources.length,
644
+ provider: dominantProvider,
645
+ region: dominantRegion,
646
+ warningCount: warnings.length
647
+ });
648
+ return {
649
+ provider: dominantProvider,
650
+ region: dominantRegion,
651
+ resources: allResources,
652
+ total_count: allResources.length,
653
+ by_type: byType,
654
+ parse_warnings: warnings
655
+ };
656
+ }
657
+
658
+ // src/tools/compare-actual.ts
659
+ var compareActualSchema = z4.object({
660
+ state_json: z4.string().describe("Contents of terraform.tfstate file (JSON)"),
661
+ files: z4.array(
662
+ z4.object({
663
+ path: z4.string().describe("File path"),
664
+ content: z4.string().describe("File content (HCL)")
665
+ })
666
+ ).optional().describe("Optional Terraform files to compare planned costs against actual"),
667
+ tfvars: z4.string().optional().describe("Contents of terraform.tfvars file"),
668
+ provider: z4.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider override. Defaults to auto-detecting from the state."),
669
+ region: z4.string().optional().describe(
670
+ "Target region override for pricing. Defaults to the region detected from the state."
671
+ ),
672
+ currency: z4.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
673
+ });
674
+ async function compareActual(params, pricingEngine, config) {
675
+ const warnings = [];
676
+ const stateInventory = parseTerraformState(params.state_json);
677
+ warnings.push(...stateInventory.parse_warnings);
678
+ const targetProvider = params.provider ?? stateInventory.provider;
679
+ const targetRegion = params.region ?? stateInventory.region;
680
+ const costEngine = new CostEngine(pricingEngine, config);
681
+ const actualBreakdown = await costEngine.calculateBreakdown(
682
+ stateInventory.resources,
683
+ targetProvider,
684
+ targetRegion
685
+ );
686
+ warnings.push(...actualBreakdown.warnings ?? []);
687
+ const actualByResource = actualBreakdown.by_resource.map((r) => ({
688
+ resource_id: r.resource_id,
689
+ resource_type: r.resource_type,
690
+ monthly_cost: Math.round(r.monthly_cost * 100) / 100
691
+ }));
692
+ const currency = params.currency ?? "USD";
693
+ const result = {
694
+ provider: targetProvider,
695
+ region: targetRegion,
696
+ currency,
697
+ actual_costs: {
698
+ total_monthly: Math.round(actualBreakdown.total_monthly * 100) / 100,
699
+ total_yearly: Math.round(actualBreakdown.total_monthly * 12 * 100) / 100,
700
+ resource_count: stateInventory.total_count,
701
+ by_resource: actualByResource
702
+ },
703
+ warnings: []
704
+ };
705
+ if (params.files && params.files.length > 0) {
706
+ const plannedInventory = await parseTerraform(params.files, params.tfvars);
707
+ warnings.push(...plannedInventory.parse_warnings);
708
+ const plannedProvider = params.provider ?? plannedInventory.provider;
709
+ const plannedRegion = params.region ?? mapRegion(plannedInventory.region, plannedInventory.provider, plannedProvider);
710
+ const plannedBreakdown = await costEngine.calculateBreakdown(
711
+ plannedInventory.resources,
712
+ plannedProvider,
713
+ plannedRegion
714
+ );
715
+ warnings.push(...plannedBreakdown.warnings ?? []);
716
+ const plannedByResource = plannedBreakdown.by_resource.map((r) => ({
717
+ resource_id: r.resource_id,
718
+ resource_type: r.resource_type,
719
+ monthly_cost: Math.round(r.monthly_cost * 100) / 100
720
+ }));
721
+ result.planned_costs = {
722
+ total_monthly: Math.round(plannedBreakdown.total_monthly * 100) / 100,
723
+ total_yearly: Math.round(plannedBreakdown.total_monthly * 12 * 100) / 100,
724
+ resource_count: plannedInventory.total_count,
725
+ by_resource: plannedByResource
726
+ };
727
+ const deltaMonthly = result.actual_costs.total_monthly - result.planned_costs.total_monthly;
728
+ const deltaYearly = result.actual_costs.total_yearly - result.planned_costs.total_yearly;
729
+ const pctChange = result.planned_costs.total_monthly === 0 ? result.actual_costs.total_monthly === 0 ? 0 : 100 : Math.round(deltaMonthly / result.planned_costs.total_monthly * 1e4) / 100;
730
+ result.delta = {
731
+ monthly: Math.round(deltaMonthly * 100) / 100,
732
+ yearly: Math.round(deltaYearly * 100) / 100,
733
+ pct_change: pctChange
734
+ };
735
+ }
736
+ if (currency !== "USD") {
737
+ const actualConverted = convertBreakdownCurrency(actualBreakdown, currency);
738
+ result.actual_costs.total_monthly = Math.round(actualConverted.total_monthly * 100) / 100;
739
+ result.actual_costs.total_yearly = Math.round(actualConverted.total_monthly * 12 * 100) / 100;
740
+ result.actual_costs.by_resource = actualConverted.by_resource.map((r) => ({
741
+ resource_id: r.resource_id,
742
+ resource_type: r.resource_type,
743
+ monthly_cost: Math.round(r.monthly_cost * 100) / 100
744
+ }));
745
+ if (result.planned_costs && result.delta) {
746
+ const deltaMonthly = result.actual_costs.total_monthly - result.planned_costs.total_monthly;
747
+ result.delta.monthly = Math.round(deltaMonthly * 100) / 100;
748
+ result.delta.yearly = Math.round(deltaMonthly * 12 * 100) / 100;
749
+ }
750
+ }
751
+ result.warnings = [...new Set(warnings)];
752
+ return result;
753
+ }
754
+
755
+ // src/tools/price-trends.ts
756
+ import { z as z5 } from "zod";
757
+ var priceTrendsSchema = z5.object({
758
+ provider: z5.enum(["aws", "azure", "gcp"]).describe("Cloud provider"),
759
+ service: z5.string().describe("Service category (e.g., ec2, rds, virtual-machines)"),
760
+ resource_type: z5.string().describe("Resource type (e.g., t3.large, Standard_D2s_v5)"),
761
+ region: z5.string().describe("Cloud region"),
762
+ limit: z5.number().optional().default(30).describe("Max number of price points to return"),
763
+ since: z5.string().optional().describe("ISO date to start from (e.g., 2024-01-01)")
764
+ });
765
+ function priceTrends(params, cache) {
766
+ const { provider, service, resource_type, region, limit, since } = params;
767
+ const history = cache.getPriceHistory(provider, service, resource_type, region, {
768
+ limit,
769
+ since
770
+ });
771
+ const priceChange = cache.getPriceChange(provider, service, resource_type, region);
772
+ const currentPrice = history.length > 0 ? { price_per_unit: history[0].price_per_unit, unit: history[0].unit } : null;
140
773
  return {
141
774
  provider,
142
- service: params.service,
143
- resource_type: params.resource_type,
144
- region: params.region,
145
- price
775
+ service,
776
+ resource_type,
777
+ region,
778
+ current_price: currentPrice,
779
+ price_history: history,
780
+ price_change: priceChange,
781
+ data_points: history.length
782
+ };
783
+ }
784
+
785
+ // src/tools/detect-anomalies.ts
786
+ import { z as z6 } from "zod";
787
+ var detectAnomaliesSchema = z6.object({
788
+ files: z6.array(
789
+ z6.object({
790
+ path: z6.string().describe("File path"),
791
+ content: z6.string().describe("File content")
792
+ })
793
+ ),
794
+ tfvars: z6.string().optional().describe("Variable overrides"),
795
+ provider: z6.enum(["aws", "azure", "gcp"]).describe("Target cloud provider"),
796
+ region: z6.string().optional().describe("Target region"),
797
+ currency: z6.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD."),
798
+ budget_monthly: z6.number().optional().describe("Monthly budget threshold in USD"),
799
+ budget_per_resource: z6.number().optional().describe("Per-resource cost threshold in USD"),
800
+ price_change_threshold: z6.number().optional().default(10).describe("Alert on price changes exceeding this percentage")
801
+ });
802
+ var MIN_VCPUS_FOR_OVERSIZING = 8;
803
+ function isOversizedInstance(instanceType) {
804
+ if (!instanceType) return false;
805
+ if (/xlarge/i.test(instanceType)) return true;
806
+ const azureMatch = instanceType.match(/Standard_[A-Z](\d+)s?_v\d+/i);
807
+ if (azureMatch) {
808
+ const vcpus = parseInt(azureMatch[1], 10);
809
+ return vcpus >= MIN_VCPUS_FOR_OVERSIZING;
810
+ }
811
+ const gcpMatch = instanceType.match(/(?:n1|n2|n2d|c2|e2)-(?:standard|highmem|highcpu)-(\d+)/);
812
+ if (gcpMatch) {
813
+ const vcpus = parseInt(gcpMatch[1], 10);
814
+ return vcpus >= MIN_VCPUS_FOR_OVERSIZING;
815
+ }
816
+ return false;
817
+ }
818
+ function serviceLabelForType(resourceType) {
819
+ if (resourceType.startsWith("aws_instance") || resourceType.startsWith("aws_eks_node_group"))
820
+ return "ec2";
821
+ if (resourceType.startsWith("azurerm_linux_virtual_machine") || resourceType.startsWith("azurerm_windows_virtual_machine"))
822
+ return "virtual-machines";
823
+ if (resourceType.startsWith("google_compute_instance") || resourceType.startsWith("google_container_node_pool"))
824
+ return "compute-engine";
825
+ if (resourceType.includes("db_instance") || resourceType.includes("sql_database") || resourceType.includes("postgresql") || resourceType.includes("mysql") || resourceType.includes("mssql"))
826
+ return "database";
827
+ if (resourceType.includes("s3") || resourceType.includes("storage_account") || resourceType.includes("storage_bucket"))
828
+ return "storage";
829
+ return "general";
830
+ }
831
+ async function detectAnomalies(params, pricingEngine, cache, config) {
832
+ const inventory = await parseTerraform(params.files, params.tfvars);
833
+ const targetProvider = params.provider;
834
+ const targetRegion = params.region ?? mapRegion(inventory.region, inventory.provider, targetProvider);
835
+ const costEngine = new CostEngine(pricingEngine, config);
836
+ const breakdown = await costEngine.calculateBreakdown(
837
+ inventory.resources,
838
+ targetProvider,
839
+ targetRegion
840
+ );
841
+ const totalMonthly = breakdown.total_monthly;
842
+ const byResource = breakdown.by_resource;
843
+ const anomalies = [];
844
+ const recommendations = [];
845
+ if (params.budget_monthly != null && totalMonthly > params.budget_monthly) {
846
+ anomalies.push({
847
+ type: "budget_exceeded",
848
+ severity: "high",
849
+ resource: "_total",
850
+ resource_type: "aggregate",
851
+ message: `Total monthly cost $${totalMonthly.toFixed(2)} exceeds budget of $${params.budget_monthly.toFixed(2)}`,
852
+ details: {
853
+ current_cost: totalMonthly,
854
+ threshold: params.budget_monthly
855
+ }
856
+ });
857
+ recommendations.push(
858
+ `Total monthly cost exceeds budget by $${(totalMonthly - params.budget_monthly).toFixed(2)}. Consider right-sizing or removing unused resources.`
859
+ );
860
+ }
861
+ for (const est of byResource) {
862
+ if (params.budget_per_resource != null && est.monthly_cost > params.budget_per_resource) {
863
+ anomalies.push({
864
+ type: "budget_exceeded",
865
+ severity: "high",
866
+ resource: est.resource_id,
867
+ resource_type: est.resource_type,
868
+ message: `Resource ${est.resource_id} costs $${est.monthly_cost.toFixed(2)}/mo, exceeding per-resource budget of $${params.budget_per_resource.toFixed(2)}`,
869
+ details: {
870
+ current_cost: est.monthly_cost,
871
+ threshold: params.budget_per_resource
872
+ }
873
+ });
874
+ }
875
+ }
876
+ const priceChangeThreshold = params.price_change_threshold ?? 10;
877
+ for (const est of byResource) {
878
+ const service = serviceLabelForType(est.resource_type);
879
+ const instanceType = resolveInstanceType(est);
880
+ const resourceKey = instanceType ?? est.resource_type;
881
+ const priceChange = cache.getPriceChange(targetProvider, service, resourceKey, targetRegion);
882
+ if (priceChange && Math.abs(priceChange.change_percent) > priceChangeThreshold) {
883
+ const direction = priceChange.change_percent > 0 ? "increased" : "decreased";
884
+ anomalies.push({
885
+ type: "price_change",
886
+ severity: Math.abs(priceChange.change_percent) > 20 ? "high" : "medium",
887
+ resource: est.resource_id,
888
+ resource_type: est.resource_type,
889
+ message: `Pricing for ${est.resource_id} has ${direction} by ${Math.abs(priceChange.change_percent).toFixed(1)}%`,
890
+ details: {
891
+ current_cost: est.monthly_cost,
892
+ price_change_percent: priceChange.change_percent
893
+ }
894
+ });
895
+ recommendations.push(
896
+ `Review pricing change for ${est.resource_id} (${Math.abs(priceChange.change_percent).toFixed(1)}% ${direction}). Consider locking in reserved pricing.`
897
+ );
898
+ }
899
+ }
900
+ if (totalMonthly > 0) {
901
+ for (const est of byResource) {
902
+ const sharePercent = est.monthly_cost / totalMonthly * 100;
903
+ if (sharePercent > 50 && byResource.length > 1) {
904
+ anomalies.push({
905
+ type: "cost_concentration",
906
+ severity: "medium",
907
+ resource: est.resource_id,
908
+ resource_type: est.resource_type,
909
+ message: `Resource ${est.resource_id} accounts for ${sharePercent.toFixed(1)}% of total cost (concentration risk)`,
910
+ details: {
911
+ current_cost: est.monthly_cost,
912
+ cost_share_percent: sharePercent
913
+ }
914
+ });
915
+ recommendations.push(
916
+ `${est.resource_id} dominates total spend at ${sharePercent.toFixed(1)}%. Evaluate whether workload can be distributed or right-sized.`
917
+ );
918
+ }
919
+ }
920
+ }
921
+ for (const est of byResource) {
922
+ const instanceType = resolveInstanceType(est);
923
+ if (isOversizedInstance(instanceType)) {
924
+ anomalies.push({
925
+ type: "potential_oversizing",
926
+ severity: "low",
927
+ resource: est.resource_id,
928
+ resource_type: est.resource_type,
929
+ message: `Instance ${est.resource_id} uses ${instanceType} which may be over-provisioned`,
930
+ details: {
931
+ current_cost: est.monthly_cost
932
+ }
933
+ });
934
+ recommendations.push(
935
+ `Consider whether ${est.resource_id} (${instanceType}) needs all allocated resources. Smaller instance types can significantly reduce cost.`
936
+ );
937
+ }
938
+ }
939
+ const high = anomalies.filter((a) => a.severity === "high").length;
940
+ const medium = anomalies.filter((a) => a.severity === "medium").length;
941
+ const low = anomalies.filter((a) => a.severity === "low").length;
942
+ return {
943
+ total_monthly: totalMonthly,
944
+ anomalies,
945
+ summary: {
946
+ total_resources: byResource.length,
947
+ anomaly_count: anomalies.length,
948
+ high_severity: high,
949
+ medium_severity: medium,
950
+ low_severity: low
951
+ },
952
+ recommendations: [...new Set(recommendations)]
146
953
  };
147
954
  }
955
+ function resolveInstanceType(est) {
956
+ for (const item of est.breakdown) {
957
+ const match = item.description.match(
958
+ /\b(t[23]\.\w+|m[5-7]\.\w+|c[5-7]\.\w+|r[5-7]\.\w+|Standard_\w+|n[12]d?-\w+-\d+|c2-\w+-\d+|e2-\w+-\d+)\b/i
959
+ );
960
+ if (match) return match[1];
961
+ }
962
+ return void 0;
963
+ }
148
964
 
149
965
  // src/tools/index.ts
150
966
  function registerTools(server, config) {
@@ -157,7 +973,7 @@ function registerTools(server, config) {
157
973
  async (params) => {
158
974
  const result = await analyzeTerraform(params);
159
975
  return {
160
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
976
+ content: [{ type: "text", text: JSON.stringify(result) }]
161
977
  };
162
978
  }
163
979
  );
@@ -168,7 +984,7 @@ function registerTools(server, config) {
168
984
  async (params) => {
169
985
  const result = await estimateCost(params, pricingEngine, config);
170
986
  return {
171
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
987
+ content: [{ type: "text", text: JSON.stringify(result) }]
172
988
  };
173
989
  }
174
990
  );
@@ -179,7 +995,7 @@ function registerTools(server, config) {
179
995
  async (params) => {
180
996
  const result = await compareProviders(params, pricingEngine, config);
181
997
  return {
182
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
998
+ content: [{ type: "text", text: JSON.stringify(result) }]
183
999
  };
184
1000
  }
185
1001
  );
@@ -190,7 +1006,7 @@ function registerTools(server, config) {
190
1006
  async (params) => {
191
1007
  const result = await getEquivalents(params);
192
1008
  return {
193
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1009
+ content: [{ type: "text", text: JSON.stringify(result) }]
194
1010
  };
195
1011
  }
196
1012
  );
@@ -201,7 +1017,7 @@ function registerTools(server, config) {
201
1017
  async (params) => {
202
1018
  const result = await getPricing(params, pricingEngine);
203
1019
  return {
204
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1020
+ content: [{ type: "text", text: JSON.stringify(result) }]
205
1021
  };
206
1022
  }
207
1023
  );
@@ -212,7 +1028,7 @@ function registerTools(server, config) {
212
1028
  async (params) => {
213
1029
  const result = await optimizeCost(params, pricingEngine, config);
214
1030
  return {
215
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1031
+ content: [{ type: "text", text: JSON.stringify(result) }]
216
1032
  };
217
1033
  }
218
1034
  );
@@ -223,7 +1039,51 @@ function registerTools(server, config) {
223
1039
  async (params) => {
224
1040
  const result = await whatIf(params, pricingEngine, config);
225
1041
  return {
226
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1042
+ content: [{ type: "text", text: JSON.stringify(result) }]
1043
+ };
1044
+ }
1045
+ );
1046
+ server.tool(
1047
+ "analyze_plan",
1048
+ "Parse Terraform plan JSON (from 'terraform show -json' or 'terraform plan -json') and return a cost-of-change analysis showing before/after costs and per-resource deltas.",
1049
+ analyzePlanSchema.shape,
1050
+ async (params) => {
1051
+ const result = await analyzePlan(params, pricingEngine, config);
1052
+ return {
1053
+ content: [{ type: "text", text: JSON.stringify(result) }]
1054
+ };
1055
+ }
1056
+ );
1057
+ server.tool(
1058
+ "compare_actual",
1059
+ "Parse a Terraform state file (.tfstate) and calculate actual infrastructure costs. Optionally compare against planned costs from HCL files to show drift.",
1060
+ compareActualSchema.shape,
1061
+ async (params) => {
1062
+ const result = await compareActual(params, pricingEngine, config);
1063
+ return {
1064
+ content: [{ type: "text", text: JSON.stringify(result) }]
1065
+ };
1066
+ }
1067
+ );
1068
+ server.tool(
1069
+ "price_trends",
1070
+ "Query historical pricing trends for a specific cloud resource. Returns recorded price history, the most recent price change, and summary metadata.",
1071
+ priceTrendsSchema.shape,
1072
+ (params) => {
1073
+ const result = priceTrends(params, cache);
1074
+ return {
1075
+ content: [{ type: "text", text: JSON.stringify(result) }]
1076
+ };
1077
+ }
1078
+ );
1079
+ server.tool(
1080
+ "detect_anomalies",
1081
+ "Analyze infrastructure costs and flag anomalies \u2014 resources whose estimated costs are unusual compared to historical baselines or configured thresholds.",
1082
+ detectAnomaliesSchema.shape,
1083
+ async (params) => {
1084
+ const result = await detectAnomalies(params, pricingEngine, cache, config);
1085
+ return {
1086
+ content: [{ type: "text", text: JSON.stringify(result) }]
227
1087
  };
228
1088
  }
229
1089
  );