awslabs.cost-explorer-mcp-server 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl
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.
- awslabs/cost_explorer_mcp_server/comparison_handler.py +719 -0
- awslabs/cost_explorer_mcp_server/constants.py +106 -0
- awslabs/cost_explorer_mcp_server/cost_usage_handler.py +385 -0
- awslabs/cost_explorer_mcp_server/forecasting_handler.py +234 -0
- awslabs/cost_explorer_mcp_server/helpers.py +501 -68
- awslabs/cost_explorer_mcp_server/metadata_handler.py +88 -0
- awslabs/cost_explorer_mcp_server/models.py +70 -0
- awslabs/cost_explorer_mcp_server/server.py +60 -463
- awslabs/cost_explorer_mcp_server/utility_handler.py +50 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/METADATA +44 -14
- awslabs_cost_explorer_mcp_server-0.0.6.dist-info/RECORD +17 -0
- awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD +0 -10
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/WHEEL +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/entry_points.txt +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Cost Explorer MCP server implementation.
|
|
16
|
+
|
|
17
|
+
Comparison tools for Cost Explorer MCP Server.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from awslabs.cost_explorer_mcp_server.constants import VALID_COST_METRICS
|
|
23
|
+
from awslabs.cost_explorer_mcp_server.helpers import (
|
|
24
|
+
create_detailed_group_key,
|
|
25
|
+
extract_group_key_from_complex_selector,
|
|
26
|
+
extract_usage_context_from_selector,
|
|
27
|
+
get_cost_explorer_client,
|
|
28
|
+
validate_comparison_date_range,
|
|
29
|
+
validate_expression,
|
|
30
|
+
validate_group_by,
|
|
31
|
+
)
|
|
32
|
+
from awslabs.cost_explorer_mcp_server.models import DateRange
|
|
33
|
+
from loguru import logger
|
|
34
|
+
from mcp.server.fastmcp import Context
|
|
35
|
+
from pydantic import Field
|
|
36
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Configure Loguru logging
|
|
40
|
+
logger.remove()
|
|
41
|
+
logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
|
|
42
|
+
|
|
43
|
+
# Constants
|
|
44
|
+
DEFAULT_GROUP_BY = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
|
|
45
|
+
DEFAULT_METRIC = 'UnblendedCost'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_comparison_inputs(
|
|
49
|
+
baseline_date_range: DateRange,
|
|
50
|
+
comparison_date_range: DateRange,
|
|
51
|
+
metric_for_comparison: str,
|
|
52
|
+
group_by: Optional[Union[Dict[str, str], str]],
|
|
53
|
+
filter_expression: Optional[Dict[str, Any]],
|
|
54
|
+
) -> Tuple[bool, Optional[str], Dict[str, Any]]:
|
|
55
|
+
"""Validate inputs and prepare comparison request parameters.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
baseline_date_range: Baseline period for comparison
|
|
59
|
+
comparison_date_range: Comparison period
|
|
60
|
+
metric_for_comparison: Cost metric to compare
|
|
61
|
+
group_by: Grouping configuration
|
|
62
|
+
filter_expression: Optional filter criteria
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (is_valid, error_message, validated_params)
|
|
66
|
+
"""
|
|
67
|
+
baseline_start = baseline_date_range.start_date
|
|
68
|
+
baseline_end = baseline_date_range.end_date
|
|
69
|
+
comparison_start = comparison_date_range.start_date
|
|
70
|
+
comparison_end = comparison_date_range.end_date
|
|
71
|
+
|
|
72
|
+
# Validate both date ranges meet comparison API requirements
|
|
73
|
+
is_valid_baseline, error_baseline = validate_comparison_date_range(
|
|
74
|
+
baseline_start, baseline_end
|
|
75
|
+
)
|
|
76
|
+
if not is_valid_baseline:
|
|
77
|
+
return False, f'Baseline period error: {error_baseline}', {}
|
|
78
|
+
|
|
79
|
+
is_valid_comparison, error_comparison = validate_comparison_date_range(
|
|
80
|
+
comparison_start, comparison_end
|
|
81
|
+
)
|
|
82
|
+
if not is_valid_comparison:
|
|
83
|
+
return False, f'Comparison period error: {error_comparison}', {}
|
|
84
|
+
|
|
85
|
+
# Validate metric
|
|
86
|
+
if metric_for_comparison not in VALID_COST_METRICS:
|
|
87
|
+
return (
|
|
88
|
+
False,
|
|
89
|
+
f'Invalid metric_for_comparison: {metric_for_comparison}. Valid values are {", ".join(VALID_COST_METRICS)}.',
|
|
90
|
+
{},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Validate filter expression if provided
|
|
94
|
+
if filter_expression:
|
|
95
|
+
validation_result = validate_expression(filter_expression, baseline_start, baseline_end)
|
|
96
|
+
if 'error' in validation_result:
|
|
97
|
+
return False, validation_result['error'], {}
|
|
98
|
+
|
|
99
|
+
# Process and validate group_by
|
|
100
|
+
if group_by is None:
|
|
101
|
+
group_by = DEFAULT_GROUP_BY.copy()
|
|
102
|
+
elif isinstance(group_by, str):
|
|
103
|
+
group_by = {'Type': 'DIMENSION', 'Key': group_by}
|
|
104
|
+
|
|
105
|
+
validation_result = validate_group_by(group_by)
|
|
106
|
+
if 'error' in validation_result:
|
|
107
|
+
return False, validation_result['error'], {}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
True,
|
|
111
|
+
None,
|
|
112
|
+
{
|
|
113
|
+
'baseline_start': baseline_start,
|
|
114
|
+
'baseline_end': baseline_end,
|
|
115
|
+
'comparison_start': comparison_start,
|
|
116
|
+
'comparison_end': comparison_end,
|
|
117
|
+
'metric': metric_for_comparison,
|
|
118
|
+
'group_by': group_by,
|
|
119
|
+
'filter_criteria': filter_expression,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_api_params(
|
|
125
|
+
baseline_start: str,
|
|
126
|
+
baseline_end: str,
|
|
127
|
+
comparison_start: str,
|
|
128
|
+
comparison_end: str,
|
|
129
|
+
metric: str,
|
|
130
|
+
group_by: Dict[str, str],
|
|
131
|
+
filter_criteria: Optional[Dict[str, Any]],
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
"""Build AWS API parameters from validated request parameters.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
baseline_start: Baseline period start date
|
|
137
|
+
baseline_end: Baseline period end date
|
|
138
|
+
comparison_start: Comparison period start date
|
|
139
|
+
comparison_end: Comparison period end date
|
|
140
|
+
metric: Cost metric to compare
|
|
141
|
+
group_by: Grouping configuration
|
|
142
|
+
filter_criteria: Optional filter criteria
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Dictionary with AWS API parameters
|
|
146
|
+
"""
|
|
147
|
+
params = {
|
|
148
|
+
'BaselineTimePeriod': {
|
|
149
|
+
'Start': baseline_start,
|
|
150
|
+
'End': baseline_end,
|
|
151
|
+
},
|
|
152
|
+
'ComparisonTimePeriod': {
|
|
153
|
+
'Start': comparison_start,
|
|
154
|
+
'End': comparison_end,
|
|
155
|
+
},
|
|
156
|
+
'MetricForComparison': metric,
|
|
157
|
+
'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if filter_criteria:
|
|
161
|
+
params['Filter'] = filter_criteria
|
|
162
|
+
|
|
163
|
+
return params
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def get_cost_and_usage_comparisons(
|
|
167
|
+
ctx: Context,
|
|
168
|
+
baseline_date_range: DateRange,
|
|
169
|
+
comparison_date_range: DateRange,
|
|
170
|
+
metric_for_comparison: str = Field(
|
|
171
|
+
'UnblendedCost',
|
|
172
|
+
description=f'The cost and usage metric to compare. Valid values are {", ".join(VALID_COST_METRICS)}.',
|
|
173
|
+
),
|
|
174
|
+
group_by: Optional[Union[Dict[str, str], str]] = Field(
|
|
175
|
+
'SERVICE',
|
|
176
|
+
description="Either a dictionary with Type and Key for grouping comparisons, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
|
|
177
|
+
),
|
|
178
|
+
filter_expression: Optional[Dict[str, Any]] = Field(
|
|
179
|
+
None,
|
|
180
|
+
description='Filter criteria as a Python dictionary to narrow down AWS cost comparisons. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. Same format as get_cost_and_usage filter_expression.',
|
|
181
|
+
),
|
|
182
|
+
) -> Dict[str, Any]:
|
|
183
|
+
"""Compare AWS costs and usage between two time periods.
|
|
184
|
+
|
|
185
|
+
This tool compares cost and usage data between a baseline period and a comparison period,
|
|
186
|
+
providing percentage changes and absolute differences. Both periods must be exactly one month
|
|
187
|
+
and start/end on the first day of a month. The tool also provides detailed cost drivers
|
|
188
|
+
when available, showing what specific factors contributed to cost changes.
|
|
189
|
+
|
|
190
|
+
Important requirements:
|
|
191
|
+
- Both periods must be exactly one month duration
|
|
192
|
+
- Dates must start and end on the first day of a month (e.g., 2025-01-01 to 2025-02-01)
|
|
193
|
+
- Maximum lookback of 13 months (38 months if multi-year data enabled)
|
|
194
|
+
- Start dates must be equal to or no later than current date
|
|
195
|
+
|
|
196
|
+
Example: Compare January 2025 vs December 2024 EC2 costs
|
|
197
|
+
await get_cost_and_usage_comparisons(
|
|
198
|
+
ctx=context,
|
|
199
|
+
baseline_date_range={
|
|
200
|
+
"start_date": "2024-12-01", # December 2024
|
|
201
|
+
"end_date": "2025-01-01"
|
|
202
|
+
},
|
|
203
|
+
comparison_date_range={
|
|
204
|
+
"start_date": "2025-01-01", # January 2025
|
|
205
|
+
"end_date": "2025-02-01"
|
|
206
|
+
},
|
|
207
|
+
metric_for_comparison="UnblendedCost",
|
|
208
|
+
group_by={"Type": "DIMENSION", "Key": "SERVICE"},
|
|
209
|
+
filter_expression={
|
|
210
|
+
"Dimensions": {
|
|
211
|
+
"Key": "SERVICE",
|
|
212
|
+
"Values": ["Amazon Elastic Compute Cloud - Compute"],
|
|
213
|
+
"MatchOptions": ["EQUALS"]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
ctx: MCP context
|
|
220
|
+
baseline_date_range: The reference period for comparison (exactly one month)
|
|
221
|
+
comparison_date_range: The comparison period (exactly one month)
|
|
222
|
+
metric_for_comparison: Cost metric to compare (UnblendedCost, BlendedCost, etc.)
|
|
223
|
+
group_by: Either a dictionary with Type and Key, or simply a string key to group by
|
|
224
|
+
filter_expression: Filter criteria as a Python dictionary
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Dictionary containing comparison data with percentage changes, absolute differences,
|
|
228
|
+
and detailed cost drivers when available
|
|
229
|
+
"""
|
|
230
|
+
# Initialize variables for error handling
|
|
231
|
+
baseline_start = baseline_date_range.start_date
|
|
232
|
+
baseline_end = baseline_date_range.end_date
|
|
233
|
+
comparison_start = comparison_date_range.start_date
|
|
234
|
+
comparison_end = comparison_date_range.end_date
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Validate inputs using validation function
|
|
238
|
+
is_valid, error_msg, validated_params = _validate_comparison_inputs(
|
|
239
|
+
baseline_date_range,
|
|
240
|
+
comparison_date_range,
|
|
241
|
+
metric_for_comparison,
|
|
242
|
+
group_by,
|
|
243
|
+
filter_expression,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if not is_valid:
|
|
247
|
+
return {'error': error_msg}
|
|
248
|
+
|
|
249
|
+
# Extract validated parameters
|
|
250
|
+
validated_baseline_start = validated_params['baseline_start']
|
|
251
|
+
validated_baseline_end = validated_params['baseline_end']
|
|
252
|
+
validated_comparison_start = validated_params['comparison_start']
|
|
253
|
+
validated_comparison_end = validated_params['comparison_end']
|
|
254
|
+
validated_metric = validated_params['metric']
|
|
255
|
+
validated_group_by = validated_params['group_by']
|
|
256
|
+
validated_filter_criteria = validated_params['filter_criteria']
|
|
257
|
+
|
|
258
|
+
# Prepare API call parameters
|
|
259
|
+
api_params = _build_api_params(
|
|
260
|
+
validated_baseline_start,
|
|
261
|
+
validated_baseline_end,
|
|
262
|
+
validated_comparison_start,
|
|
263
|
+
validated_comparison_end,
|
|
264
|
+
validated_metric,
|
|
265
|
+
validated_group_by,
|
|
266
|
+
validated_filter_criteria,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Get comparison data
|
|
270
|
+
grouped_comparisons = {}
|
|
271
|
+
next_token = None
|
|
272
|
+
ce = get_cost_explorer_client()
|
|
273
|
+
|
|
274
|
+
while True:
|
|
275
|
+
if next_token:
|
|
276
|
+
api_params['NextPageToken'] = next_token
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
response = ce.get_cost_and_usage_comparisons(**api_params)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f'Error calling Cost Explorer comparison API: {e}')
|
|
282
|
+
return {'error': f'AWS Cost Explorer comparison API error: {str(e)}'}
|
|
283
|
+
|
|
284
|
+
# Process comparison results
|
|
285
|
+
for comparison_result in response.get('CostAndUsageComparisons', []):
|
|
286
|
+
# Extract group key from CostAndUsageSelector
|
|
287
|
+
selector = comparison_result.get('CostAndUsageSelector', {})
|
|
288
|
+
group_key = 'Unknown'
|
|
289
|
+
|
|
290
|
+
# Extract the actual dimension value (e.g., service name)
|
|
291
|
+
if 'Dimensions' in selector:
|
|
292
|
+
dimension_info = selector['Dimensions']
|
|
293
|
+
if 'Values' in dimension_info and dimension_info['Values']:
|
|
294
|
+
group_key = dimension_info['Values'][0] # Use the first value as group key
|
|
295
|
+
elif 'Tags' in selector:
|
|
296
|
+
tag_info = selector['Tags']
|
|
297
|
+
if 'Values' in tag_info and tag_info['Values']:
|
|
298
|
+
group_key = f'{tag_info.get("Key", "Tag")}:{tag_info["Values"][0]}'
|
|
299
|
+
elif 'CostCategories' in selector:
|
|
300
|
+
cc_info = selector['CostCategories']
|
|
301
|
+
if 'Values' in cc_info and cc_info['Values']:
|
|
302
|
+
group_key = f'{cc_info.get("Key", "Category")}:{cc_info["Values"][0]}'
|
|
303
|
+
|
|
304
|
+
# Process metrics for this group
|
|
305
|
+
metrics = comparison_result.get('Metrics', {})
|
|
306
|
+
|
|
307
|
+
for metric_name, metric_data in metrics.items():
|
|
308
|
+
if metric_name == metric_for_comparison:
|
|
309
|
+
baseline_amount = float(metric_data.get('BaselineTimePeriodAmount', 0))
|
|
310
|
+
comparison_amount = float(metric_data.get('ComparisonTimePeriodAmount', 0))
|
|
311
|
+
difference = float(metric_data.get('Difference', 0))
|
|
312
|
+
unit = metric_data.get('Unit', 'USD')
|
|
313
|
+
|
|
314
|
+
# Calculate percentage change
|
|
315
|
+
if baseline_amount != 0:
|
|
316
|
+
percentage_change = (difference / baseline_amount) * 100
|
|
317
|
+
else:
|
|
318
|
+
percentage_change = 100.0 if comparison_amount > 0 else 0.0
|
|
319
|
+
|
|
320
|
+
grouped_comparisons[group_key] = {
|
|
321
|
+
'baseline_value': round(baseline_amount, 2),
|
|
322
|
+
'comparison_value': round(comparison_amount, 2),
|
|
323
|
+
'absolute_change': round(difference, 2),
|
|
324
|
+
'percentage_change': round(percentage_change, 2),
|
|
325
|
+
'unit': unit,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
next_token = response.get('NextPageToken')
|
|
329
|
+
if not next_token:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
# Process total cost and usage
|
|
333
|
+
total_data = {}
|
|
334
|
+
total_cost_and_usage = response.get('TotalCostAndUsage', {})
|
|
335
|
+
|
|
336
|
+
for metric_name, metric_data in total_cost_and_usage.items():
|
|
337
|
+
if metric_name == metric_for_comparison:
|
|
338
|
+
baseline_total = float(metric_data.get('BaselineTimePeriodAmount', 0))
|
|
339
|
+
comparison_total = float(metric_data.get('ComparisonTimePeriodAmount', 0))
|
|
340
|
+
difference_total = float(metric_data.get('Difference', 0))
|
|
341
|
+
unit = metric_data.get('Unit', 'USD')
|
|
342
|
+
|
|
343
|
+
# Calculate total percentage change
|
|
344
|
+
if baseline_total != 0:
|
|
345
|
+
total_percentage_change = (difference_total / baseline_total) * 100
|
|
346
|
+
else:
|
|
347
|
+
total_percentage_change = 100.0 if comparison_total > 0 else 0.0
|
|
348
|
+
|
|
349
|
+
total_data = {
|
|
350
|
+
'baseline_value': round(baseline_total, 2),
|
|
351
|
+
'comparison_value': round(comparison_total, 2),
|
|
352
|
+
'absolute_change': round(difference_total, 2),
|
|
353
|
+
'percentage_change': round(total_percentage_change, 2),
|
|
354
|
+
'unit': unit,
|
|
355
|
+
}
|
|
356
|
+
break # We found our metric
|
|
357
|
+
|
|
358
|
+
# If no total data was found, calculate from grouped data
|
|
359
|
+
if not total_data and grouped_comparisons:
|
|
360
|
+
total_baseline = sum(comp['baseline_value'] for comp in grouped_comparisons.values())
|
|
361
|
+
total_comparison = sum(
|
|
362
|
+
comp['comparison_value'] for comp in grouped_comparisons.values()
|
|
363
|
+
)
|
|
364
|
+
total_difference = sum(
|
|
365
|
+
comp['absolute_change'] for comp in grouped_comparisons.values()
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if total_baseline != 0:
|
|
369
|
+
total_percentage_change = (total_difference / total_baseline) * 100
|
|
370
|
+
else:
|
|
371
|
+
total_percentage_change = 100.0 if total_comparison > 0 else 0.0
|
|
372
|
+
|
|
373
|
+
total_data = {
|
|
374
|
+
'baseline_value': round(total_baseline, 2),
|
|
375
|
+
'comparison_value': round(total_comparison, 2),
|
|
376
|
+
'absolute_change': round(total_difference, 2),
|
|
377
|
+
'percentage_change': round(total_percentage_change, 2),
|
|
378
|
+
'unit': list(grouped_comparisons.values())[0].get('unit', 'USD')
|
|
379
|
+
if grouped_comparisons
|
|
380
|
+
else 'USD',
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Build response
|
|
384
|
+
result = {
|
|
385
|
+
'baseline_period': f'{baseline_start} to {baseline_end}',
|
|
386
|
+
'comparison_period': f'{comparison_start} to {comparison_end}',
|
|
387
|
+
'metric': metric_for_comparison,
|
|
388
|
+
'grouped_by': validated_group_by['Key'],
|
|
389
|
+
'comparisons': grouped_comparisons,
|
|
390
|
+
'total_comparison': total_data,
|
|
391
|
+
'metadata': {
|
|
392
|
+
'grouping_type': validated_group_by['Type'],
|
|
393
|
+
'total_groups': len(grouped_comparisons),
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.error(
|
|
401
|
+
f'Error generating cost comparison between {baseline_start}-{baseline_end} and {comparison_start}-{comparison_end}: {e}'
|
|
402
|
+
)
|
|
403
|
+
return {'error': f'Error generating cost comparison: {str(e)}'}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def get_cost_comparison_drivers(
|
|
407
|
+
ctx: Context,
|
|
408
|
+
baseline_date_range: DateRange,
|
|
409
|
+
comparison_date_range: DateRange,
|
|
410
|
+
metric_for_comparison: str = Field(
|
|
411
|
+
'UnblendedCost',
|
|
412
|
+
description=f'The cost and usage metric to analyze drivers for. Valid values are {", ".join(VALID_COST_METRICS)}.',
|
|
413
|
+
),
|
|
414
|
+
group_by: Optional[Union[Dict[str, str], str]] = Field(
|
|
415
|
+
'SERVICE',
|
|
416
|
+
description="Either a dictionary with Type and Key for grouping driver analysis, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
|
|
417
|
+
),
|
|
418
|
+
filter_expression: Optional[Dict[str, Any]] = Field(
|
|
419
|
+
None,
|
|
420
|
+
description='Filter criteria as a Python dictionary to narrow down AWS cost driver analysis. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. Same format as get_cost_and_usage filter_expression.',
|
|
421
|
+
),
|
|
422
|
+
) -> Dict[str, Any]:
|
|
423
|
+
"""Analyze what drove cost changes between two time periods.
|
|
424
|
+
|
|
425
|
+
This tool provides detailed analysis of the TOP 10 most significant cost drivers
|
|
426
|
+
that caused changes between periods. AWS returns only the most impactful drivers
|
|
427
|
+
to focus on the changes that matter most for cost optimization.
|
|
428
|
+
|
|
429
|
+
The tool provides rich insights including:
|
|
430
|
+
- Top 10 most significant cost drivers across all services (or filtered subset)
|
|
431
|
+
- Specific usage types that drove changes (e.g., "BoxUsage:c5.large", "NatGateway-Hours")
|
|
432
|
+
- Multiple driver types: usage changes, savings plan impacts, enterprise discounts, support fees
|
|
433
|
+
- Both cost and usage quantity changes with units (hours, GB-months, etc.)
|
|
434
|
+
- Context about what infrastructure components changed
|
|
435
|
+
- Detailed breakdown of usage patterns vs pricing changes
|
|
436
|
+
|
|
437
|
+
Can be used with or without filters:
|
|
438
|
+
- Without filters: Shows top 10 cost drivers across ALL services
|
|
439
|
+
- With filters: Shows top 10 cost drivers within the filtered scope
|
|
440
|
+
- Multiple services: Can filter to multiple services and get top 10 within that scope
|
|
441
|
+
|
|
442
|
+
Both periods must be exactly one month and start/end on the first day of a month.
|
|
443
|
+
|
|
444
|
+
Important requirements:
|
|
445
|
+
- Both periods must be exactly one month duration
|
|
446
|
+
- Dates must start and end on the first day of a month (e.g., 2025-01-01 to 2025-02-01)
|
|
447
|
+
- Maximum lookback of 13 months (38 months if multi-year data enabled)
|
|
448
|
+
- Start dates must be equal to or no later than current date
|
|
449
|
+
- Results limited to top 10 most significant drivers (no pagination)
|
|
450
|
+
|
|
451
|
+
Example: Analyze top 10 cost drivers across all services
|
|
452
|
+
await get_cost_comparison_drivers(
|
|
453
|
+
ctx=context,
|
|
454
|
+
baseline_date_range={
|
|
455
|
+
"start_date": "2024-12-01", # December 2024
|
|
456
|
+
"end_date": "2025-01-01"
|
|
457
|
+
},
|
|
458
|
+
comparison_date_range={
|
|
459
|
+
"start_date": "2025-01-01", # January 2025
|
|
460
|
+
"end_date": "2025-02-01"
|
|
461
|
+
},
|
|
462
|
+
metric_for_comparison="UnblendedCost",
|
|
463
|
+
group_by={"Type": "DIMENSION", "Key": "SERVICE"}
|
|
464
|
+
# No filter = top 10 drivers across all services
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
Example: Analyze top 10 cost drivers for specific services
|
|
468
|
+
await get_cost_comparison_drivers(
|
|
469
|
+
ctx=context,
|
|
470
|
+
baseline_date_range={
|
|
471
|
+
"start_date": "2024-12-01",
|
|
472
|
+
"end_date": "2025-01-01"
|
|
473
|
+
},
|
|
474
|
+
comparison_date_range={
|
|
475
|
+
"start_date": "2025-01-01",
|
|
476
|
+
"end_date": "2025-02-01"
|
|
477
|
+
},
|
|
478
|
+
metric_for_comparison="UnblendedCost",
|
|
479
|
+
group_by={"Type": "DIMENSION", "Key": "SERVICE"},
|
|
480
|
+
filter_expression={
|
|
481
|
+
"Dimensions": {
|
|
482
|
+
"Key": "SERVICE",
|
|
483
|
+
"Values": ["Amazon Elastic Compute Cloud - Compute", "Amazon Simple Storage Service"],
|
|
484
|
+
"MatchOptions": ["EQUALS"]
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
ctx: MCP context
|
|
491
|
+
baseline_date_range: The reference period for comparison (exactly one month)
|
|
492
|
+
comparison_date_range: The comparison period (exactly one month)
|
|
493
|
+
metric_for_comparison: Cost metric to analyze drivers for (UnblendedCost, BlendedCost, etc.)
|
|
494
|
+
group_by: Either a dictionary with Type and Key, or simply a string key to group by
|
|
495
|
+
filter_expression: Filter criteria as a Python dictionary
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
with specific usage types, usage quantity changes, driver types (savings plans, discounts, usage changes, support fees), and contextual information
|
|
499
|
+
"""
|
|
500
|
+
# Initialize variables for error handling
|
|
501
|
+
baseline_start = baseline_date_range.start_date
|
|
502
|
+
baseline_end = baseline_date_range.end_date
|
|
503
|
+
comparison_start = comparison_date_range.start_date
|
|
504
|
+
comparison_end = comparison_date_range.end_date
|
|
505
|
+
try:
|
|
506
|
+
# Validate inputs using validation function
|
|
507
|
+
is_valid, error_msg, validated_params = _validate_comparison_inputs(
|
|
508
|
+
baseline_date_range,
|
|
509
|
+
comparison_date_range,
|
|
510
|
+
metric_for_comparison,
|
|
511
|
+
group_by,
|
|
512
|
+
filter_expression,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if not is_valid:
|
|
516
|
+
return {'error': error_msg}
|
|
517
|
+
|
|
518
|
+
# Extract validated parameters
|
|
519
|
+
validated_baseline_start = validated_params['baseline_start']
|
|
520
|
+
validated_baseline_end = validated_params['baseline_end']
|
|
521
|
+
validated_comparison_start = validated_params['comparison_start']
|
|
522
|
+
validated_comparison_end = validated_params['comparison_end']
|
|
523
|
+
validated_metric = validated_params['metric']
|
|
524
|
+
validated_group_by = validated_params['group_by']
|
|
525
|
+
validated_filter_criteria = validated_params['filter_criteria']
|
|
526
|
+
|
|
527
|
+
# Prepare API call parameters
|
|
528
|
+
driver_api_params = _build_api_params(
|
|
529
|
+
validated_baseline_start,
|
|
530
|
+
validated_baseline_end,
|
|
531
|
+
validated_comparison_start,
|
|
532
|
+
validated_comparison_end,
|
|
533
|
+
validated_metric,
|
|
534
|
+
validated_group_by,
|
|
535
|
+
validated_filter_criteria,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Get cost driver data
|
|
539
|
+
grouped_drivers = {}
|
|
540
|
+
next_token = None
|
|
541
|
+
ce = get_cost_explorer_client()
|
|
542
|
+
|
|
543
|
+
while True:
|
|
544
|
+
if next_token:
|
|
545
|
+
driver_api_params['NextPageToken'] = next_token
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
response = ce.get_cost_comparison_drivers(**driver_api_params)
|
|
549
|
+
except Exception as e:
|
|
550
|
+
logger.error(f'Error calling Cost Explorer comparison drivers API: {e}')
|
|
551
|
+
return {'error': f'AWS Cost Explorer comparison drivers API error: {str(e)}'}
|
|
552
|
+
|
|
553
|
+
# Process cost comparison drivers
|
|
554
|
+
for driver_result in response.get('CostComparisonDrivers', []):
|
|
555
|
+
# Extract group key from CostSelector using improved logic
|
|
556
|
+
selector = driver_result.get('CostSelector', {})
|
|
557
|
+
group_key = extract_group_key_from_complex_selector(selector, validated_group_by)
|
|
558
|
+
|
|
559
|
+
# Extract comprehensive context (service, usage type, region, etc.)
|
|
560
|
+
usage_context = extract_usage_context_from_selector(selector)
|
|
561
|
+
|
|
562
|
+
# Create detailed group key with context
|
|
563
|
+
detailed_key = create_detailed_group_key(
|
|
564
|
+
group_key, usage_context, validated_group_by
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Process metrics for this group
|
|
568
|
+
metrics = driver_result.get('Metrics', {})
|
|
569
|
+
|
|
570
|
+
for metric_name, metric_data in metrics.items():
|
|
571
|
+
if metric_name == metric_for_comparison:
|
|
572
|
+
baseline_amount = float(metric_data.get('BaselineTimePeriodAmount', 0))
|
|
573
|
+
comparison_amount = float(metric_data.get('ComparisonTimePeriodAmount', 0))
|
|
574
|
+
difference = float(metric_data.get('Difference', 0))
|
|
575
|
+
unit = metric_data.get('Unit', 'USD')
|
|
576
|
+
|
|
577
|
+
# Calculate percentage change
|
|
578
|
+
if baseline_amount != 0:
|
|
579
|
+
percentage_change = (difference / baseline_amount) * 100
|
|
580
|
+
else:
|
|
581
|
+
percentage_change = 100.0 if comparison_amount > 0 else 0.0
|
|
582
|
+
|
|
583
|
+
driver_data = {
|
|
584
|
+
'baseline_value': round(baseline_amount, 2),
|
|
585
|
+
'comparison_value': round(comparison_amount, 2),
|
|
586
|
+
'absolute_change': round(difference, 2),
|
|
587
|
+
'percentage_change': round(percentage_change, 2),
|
|
588
|
+
'unit': unit,
|
|
589
|
+
'context': usage_context, # Full context information
|
|
590
|
+
'primary_group_key': group_key, # The actual group key value
|
|
591
|
+
'cost_drivers': [],
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
# Process detailed cost drivers
|
|
595
|
+
cost_drivers = driver_result.get('CostDrivers', [])
|
|
596
|
+
for driver in cost_drivers:
|
|
597
|
+
driver_metrics = driver.get('Metrics', {})
|
|
598
|
+
|
|
599
|
+
# Process the main comparison metric
|
|
600
|
+
if metric_for_comparison in driver_metrics:
|
|
601
|
+
driver_metric_data = driver_metrics[metric_for_comparison]
|
|
602
|
+
driver_baseline = float(
|
|
603
|
+
driver_metric_data.get('BaselineTimePeriodAmount', 0)
|
|
604
|
+
)
|
|
605
|
+
driver_comparison = float(
|
|
606
|
+
driver_metric_data.get('ComparisonTimePeriodAmount', 0)
|
|
607
|
+
)
|
|
608
|
+
driver_difference = float(driver_metric_data.get('Difference', 0))
|
|
609
|
+
|
|
610
|
+
# Calculate driver percentage change
|
|
611
|
+
if driver_baseline != 0:
|
|
612
|
+
driver_percentage = (driver_difference / driver_baseline) * 100
|
|
613
|
+
else:
|
|
614
|
+
driver_percentage = 100.0 if driver_comparison > 0 else 0.0
|
|
615
|
+
|
|
616
|
+
driver_info = {
|
|
617
|
+
'type': driver.get('Type', 'Unknown'),
|
|
618
|
+
'name': driver.get('Name', 'Unknown'),
|
|
619
|
+
'baseline_value': round(driver_baseline, 2),
|
|
620
|
+
'comparison_value': round(driver_comparison, 2),
|
|
621
|
+
'absolute_change': round(driver_difference, 2),
|
|
622
|
+
'percentage_change': round(driver_percentage, 2),
|
|
623
|
+
'unit': driver_metric_data.get('Unit', 'USD'),
|
|
624
|
+
'additional_metrics': {},
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
# Process additional metrics (like UsageQuantity)
|
|
628
|
+
for additional_metric, additional_data in driver_metrics.items():
|
|
629
|
+
if additional_metric != metric_for_comparison:
|
|
630
|
+
add_baseline = float(
|
|
631
|
+
additional_data.get('BaselineTimePeriodAmount', 0)
|
|
632
|
+
)
|
|
633
|
+
add_comparison = float(
|
|
634
|
+
additional_data.get('ComparisonTimePeriodAmount', 0)
|
|
635
|
+
)
|
|
636
|
+
add_difference = float(
|
|
637
|
+
additional_data.get('Difference', 0)
|
|
638
|
+
)
|
|
639
|
+
add_unit = additional_data.get('Unit', '')
|
|
640
|
+
|
|
641
|
+
# Calculate percentage for additional metric
|
|
642
|
+
if add_baseline != 0:
|
|
643
|
+
add_percentage = (add_difference / add_baseline) * 100
|
|
644
|
+
else:
|
|
645
|
+
add_percentage = 100.0 if add_comparison > 0 else 0.0
|
|
646
|
+
|
|
647
|
+
driver_info['additional_metrics'][additional_metric] = {
|
|
648
|
+
'baseline_value': round(add_baseline, 2),
|
|
649
|
+
'comparison_value': round(add_comparison, 2),
|
|
650
|
+
'absolute_change': round(add_difference, 2),
|
|
651
|
+
'percentage_change': round(add_percentage, 2),
|
|
652
|
+
'unit': add_unit,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
driver_data['cost_drivers'].append(driver_info)
|
|
656
|
+
|
|
657
|
+
# Sort cost drivers by absolute impact (descending)
|
|
658
|
+
driver_data['cost_drivers'].sort(
|
|
659
|
+
key=lambda x: abs(x['absolute_change']), reverse=True
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
grouped_drivers[detailed_key] = driver_data
|
|
663
|
+
|
|
664
|
+
next_token = response.get('NextPageToken')
|
|
665
|
+
if not next_token:
|
|
666
|
+
break
|
|
667
|
+
|
|
668
|
+
# Calculate totals from grouped data
|
|
669
|
+
total_baseline = sum(driver['baseline_value'] for driver in grouped_drivers.values())
|
|
670
|
+
total_comparison = sum(driver['comparison_value'] for driver in grouped_drivers.values())
|
|
671
|
+
total_difference = sum(driver['absolute_change'] for driver in grouped_drivers.values())
|
|
672
|
+
|
|
673
|
+
if total_baseline != 0:
|
|
674
|
+
total_percentage_change = (total_difference / total_baseline) * 100
|
|
675
|
+
else:
|
|
676
|
+
total_percentage_change = 100.0 if total_comparison > 0 else 0.0
|
|
677
|
+
|
|
678
|
+
# Build response
|
|
679
|
+
result = {
|
|
680
|
+
'baseline_period': f'{baseline_start} to {baseline_end}',
|
|
681
|
+
'comparison_period': f'{comparison_start} to {comparison_end}',
|
|
682
|
+
'metric': metric_for_comparison,
|
|
683
|
+
'grouped_by': validated_group_by['Key'],
|
|
684
|
+
'driver_analysis': grouped_drivers,
|
|
685
|
+
'total_analysis': {
|
|
686
|
+
'baseline_value': round(total_baseline, 2),
|
|
687
|
+
'comparison_value': round(total_comparison, 2),
|
|
688
|
+
'absolute_change': round(total_difference, 2),
|
|
689
|
+
'percentage_change': round(total_percentage_change, 2),
|
|
690
|
+
'unit': list(grouped_drivers.values())[0].get('unit', 'USD')
|
|
691
|
+
if grouped_drivers
|
|
692
|
+
else 'USD',
|
|
693
|
+
},
|
|
694
|
+
'metadata': {
|
|
695
|
+
'grouping_type': validated_group_by['Type'],
|
|
696
|
+
'total_groups': len(grouped_drivers),
|
|
697
|
+
'total_drivers': sum(
|
|
698
|
+
len(driver.get('cost_drivers', [])) for driver in grouped_drivers.values()
|
|
699
|
+
),
|
|
700
|
+
'has_usage_context': any(
|
|
701
|
+
driver.get('context') for driver in grouped_drivers.values()
|
|
702
|
+
),
|
|
703
|
+
'has_additional_metrics': any(
|
|
704
|
+
any(
|
|
705
|
+
cost_driver.get('additional_metrics')
|
|
706
|
+
for cost_driver in driver.get('cost_drivers', [])
|
|
707
|
+
)
|
|
708
|
+
for driver in grouped_drivers.values()
|
|
709
|
+
),
|
|
710
|
+
},
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return result
|
|
714
|
+
|
|
715
|
+
except Exception as e:
|
|
716
|
+
logger.error(
|
|
717
|
+
f'Error generating cost driver analysis between {baseline_start}-{baseline_end} and {comparison_start}-{comparison_end}: {e}'
|
|
718
|
+
)
|
|
719
|
+
return {'error': f'Error generating cost driver analysis: {str(e)}'}
|