awslabs.cost-explorer-mcp-server 0.0.5__py3-none-any.whl → 0.0.7__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.
@@ -0,0 +1,106 @@
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
+ """Constants for the Cost Explorer MCP server."""
16
+
17
+ from typing import List
18
+
19
+
20
+ # AWS Cost Explorer supported dimensions
21
+ VALID_DIMENSIONS: List[str] = [
22
+ 'AZ', # The Availability Zone. An example is us-east-1a.
23
+ 'BILLING_ENTITY', # The Amazon Web Services seller that your account is with
24
+ 'CACHE_ENGINE', # The Amazon ElastiCache operating system. Examples are Windows or Linux.
25
+ 'DEPLOYMENT_OPTION', # The scope of Amazon Relational Database Service deployments. Valid values are SingleAZ and MultiAZ.
26
+ 'DATABASE_ENGINE', # The Amazon Relational Database Service database. Examples are Aurora or MySQL.
27
+ 'INSTANCE_TYPE', # The type of Amazon EC2 instance. An example is m4.xlarge.
28
+ 'INSTANCE_TYPE_FAMILY', # A family of instance types optimized to fit different use cases
29
+ 'INVOICING_ENTITY', # The name of the entity that issues the Amazon Web Services invoice
30
+ 'LEGAL_ENTITY_NAME', # The name of the organization that sells you Amazon Web Services services
31
+ 'LINKED_ACCOUNT', # The description in the attribute map that includes the full name of the member account
32
+ 'OPERATING_SYSTEM', # The operating system. Examples are Windows or Linux.
33
+ 'OPERATION', # The action performed. Examples include RunInstance and CreateBucket.
34
+ 'PLATFORM', # The Amazon EC2 operating system. Examples are Windows or Linux.
35
+ 'PURCHASE_TYPE', # The reservation type of the purchase that this usage is related to
36
+ 'RESERVATION_ID', # The unique identifier for an Amazon Web Services Reservation Instance
37
+ 'SAVINGS_PLAN_ARN', # The unique identifier for your Savings Plans
38
+ 'SAVINGS_PLANS_TYPE', # Type of Savings Plans (EC2 Instance or Compute)
39
+ 'SERVICE', # The Amazon Web Services service such as Amazon DynamoDB
40
+ 'TENANCY', # The tenancy of a resource. Examples are shared or dedicated.
41
+ 'USAGE_TYPE', # The type of usage. An example is DataTransfer-In-Bytes
42
+ 'USAGE_TYPE_GROUP', # The grouping of common usage types. An example is Amazon EC2: CloudWatch – Alarms
43
+ 'REGION', # The Amazon Web Services Region
44
+ 'RECORD_TYPE', # The different types of charges such as Reserved Instance (RI) fees, usage costs, tax refunds, and credits
45
+ ]
46
+
47
+ # Valid cost metrics for AWS Cost Explorer
48
+ VALID_COST_METRICS: List[str] = [
49
+ 'AmortizedCost',
50
+ 'BlendedCost',
51
+ 'NetAmortizedCost',
52
+ 'NetUnblendedCost',
53
+ 'UnblendedCost',
54
+ 'UsageQuantity',
55
+ ]
56
+
57
+ # Valid granularity options for AWS Cost Explorer
58
+ VALID_GRANULARITIES: List[str] = ['DAILY', 'MONTHLY', 'HOURLY']
59
+
60
+ # Valid forecast granularities (subset of VALID_GRANULARITIES)
61
+ VALID_FORECAST_GRANULARITIES: List[str] = ['DAILY', 'MONTHLY']
62
+
63
+ # Valid match options for different filter types
64
+ VALID_MATCH_OPTIONS = {
65
+ 'Dimensions': ['EQUALS', 'CASE_SENSITIVE'],
66
+ 'Tags': ['EQUALS', 'ABSENT', 'CASE_SENSITIVE'],
67
+ 'CostCategories': ['EQUALS', 'ABSENT', 'CASE_SENSITIVE'],
68
+ }
69
+
70
+ # Valid group by types for AWS Cost Explorer
71
+ VALID_GROUP_BY_TYPES: List[str] = ['DIMENSION', 'TAG', 'COST_CATEGORY']
72
+
73
+ # Valid dimension keys for GROUP BY operations (subset of VALID_DIMENSIONS)
74
+ VALID_GROUP_BY_DIMENSIONS: List[str] = [
75
+ 'AZ',
76
+ 'INSTANCE_TYPE',
77
+ 'LEGAL_ENTITY_NAME',
78
+ 'INVOICING_ENTITY',
79
+ 'LINKED_ACCOUNT',
80
+ 'OPERATION',
81
+ 'PLATFORM',
82
+ 'PURCHASE_TYPE',
83
+ 'SERVICE',
84
+ 'TENANCY',
85
+ 'RECORD_TYPE',
86
+ 'USAGE_TYPE',
87
+ 'REGION',
88
+ 'DATABASE_ENGINE',
89
+ 'INSTANCE_TYPE_FAMILY',
90
+ 'OPERATING_SYSTEM',
91
+ 'CACHE_ENGINE',
92
+ 'DEPLOYMENT_OPTION',
93
+ 'BILLING_ENTITY',
94
+ ]
95
+
96
+ # Valid forecast metrics (UsageQuantity forecasting is not supported by AWS)
97
+ VALID_FORECAST_METRICS: List[str] = [
98
+ 'AMORTIZED_COST',
99
+ 'BLENDED_COST',
100
+ 'NET_AMORTIZED_COST',
101
+ 'NET_UNBLENDED_COST',
102
+ 'UNBLENDED_COST',
103
+ ]
104
+
105
+ # Valid prediction interval levels for forecasts
106
+ VALID_PREDICTION_INTERVALS: List[int] = [80, 95]
@@ -0,0 +1,385 @@
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
+ This server provides tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import pandas as pd
23
+ import sys
24
+ from awslabs.cost_explorer_mcp_server.constants import (
25
+ VALID_COST_METRICS,
26
+ VALID_GRANULARITIES,
27
+ VALID_MATCH_OPTIONS,
28
+ )
29
+ from awslabs.cost_explorer_mcp_server.helpers import (
30
+ format_date_for_api,
31
+ get_cost_explorer_client,
32
+ validate_expression,
33
+ validate_group_by,
34
+ )
35
+ from awslabs.cost_explorer_mcp_server.models import DateRange
36
+ from datetime import datetime, timedelta
37
+ from loguru import logger
38
+ from mcp.server.fastmcp import Context
39
+ from pydantic import Field
40
+ from typing import Any, Dict, Optional, Union
41
+
42
+
43
+ # Configure Loguru logging
44
+ logger.remove()
45
+ logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
46
+
47
+ # Constants
48
+ COST_EXPLORER_END_DATE_OFFSET = 1 # Offset to ensure end date is inclusive
49
+
50
+
51
+ async def get_cost_and_usage(
52
+ ctx: Context,
53
+ date_range: DateRange,
54
+ granularity: str = Field(
55
+ 'MONTHLY',
56
+ description=f'The granularity at which cost data is aggregated. Valid values are {", ".join(VALID_GRANULARITIES)}. If not provided, defaults to MONTHLY.',
57
+ ),
58
+ group_by: Optional[Union[Dict[str, str], str]] = Field(
59
+ 'SERVICE',
60
+ description="Either a dictionary with Type and Key for grouping costs, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
61
+ ),
62
+ filter_expression: Optional[Dict[str, Any]] = Field(
63
+ None,
64
+ description=f"Filter criteria as a Python dictionary to narrow down AWS costs. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. MatchOptions validation: For Dimensions, valid values are {VALID_MATCH_OPTIONS['Dimensions']}. For Tags and CostCategories, valid values are {VALID_MATCH_OPTIONS['Tags']} (defaults to EQUALS and CASE_SENSITIVE). Examples: 1) Simple service filter: {{'Dimensions': {{'Key': 'SERVICE', 'Values': ['Amazon Elastic Compute Cloud - Compute', 'Amazon Simple Storage Service'], 'MatchOptions': ['EQUALS']}}}}. 2) Region filter: {{'Dimensions': {{'Key': 'REGION', 'Values': ['us-east-1'], 'MatchOptions': ['EQUALS']}}}}. 3) Combined filter: {{'And': [{{'Dimensions': {{'Key': 'SERVICE', 'Values': ['Amazon Elastic Compute Cloud - Compute'], 'MatchOptions': ['EQUALS']}}}}, {{'Dimensions': {{'Key': 'REGION', 'Values': ['us-east-1'], 'MatchOptions': ['EQUALS']}}}}]}}.",
65
+ ),
66
+ metric: str = Field(
67
+ 'UnblendedCost',
68
+ description=f'The metric to return in the query. Valid values are {", ".join(VALID_COST_METRICS)}. IMPORTANT: For UsageQuantity, the service aggregates usage numbers without considering units, making results meaningless when mixing different unit types (e.g., compute hours + data transfer GB). To get meaningful UsageQuantity metrics, you MUST filter by USAGE_TYPE or group by USAGE_TYPE/USAGE_TYPE_GROUP to ensure consistent units.',
69
+ ),
70
+ ) -> Dict[str, Any]:
71
+ """Retrieve AWS cost and usage data.
72
+
73
+ This tool retrieves AWS cost and usage data for AWS services during a specified billing period,
74
+ with optional filtering and grouping. It dynamically generates cost reports tailored to specific needs
75
+ by specifying parameters such as granularity, billing period dates, and filter criteria.
76
+
77
+ Note: The end_date is treated as inclusive in this tool, meaning if you specify an end_date of
78
+ "2025-01-31", the results will include data for January 31st. This differs from the AWS Cost Explorer
79
+ API which treats end_date as exclusive.
80
+
81
+ IMPORTANT: When using UsageQuantity metric, AWS aggregates usage numbers without considering units.
82
+ This makes results meaningless when different usage types have different units (e.g., EC2 compute hours
83
+ vs data transfer GB). For meaningful UsageQuantity results, you MUST be very specific with filtering, including USAGE_TYPE or USAGE_TYPE_GROUP.
84
+
85
+ Example: Get monthly costs for EC2 and S3 services in us-east-1 for May 2025
86
+ await get_cost_and_usage(
87
+ ctx=context,
88
+ date_range={
89
+ "start_date": "2025-05-01",
90
+ "end_date": "2025-05-31"
91
+ },
92
+ granularity="MONTHLY",
93
+ group_by={"Type": "DIMENSION", "Key": "SERVICE"},
94
+ filter_expression={
95
+ "And": [
96
+ {
97
+ "Dimensions": {
98
+ "Key": "SERVICE",
99
+ "Values": ["Amazon Elastic Compute Cloud - Compute", "Amazon Simple Storage Service"],
100
+ "MatchOptions": ["EQUALS"]
101
+ }
102
+ },
103
+ {
104
+ "Dimensions": {
105
+ "Key": "REGION",
106
+ "Values": ["us-east-1"],
107
+ "MatchOptions": ["EQUALS"]
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ metric="UnblendedCost"
113
+ )
114
+
115
+ Example: Get meaningful UsageQuantity for specific EC2 instance usage
116
+ await get_cost_and_usage(
117
+ ctx=context,
118
+ {
119
+ "date_range": {
120
+ "start_date": "2025-05-01",
121
+ "end_date": "2025-05-31"
122
+ },
123
+ "filter_expression": {
124
+ "And": [
125
+ {
126
+ "Dimensions": {
127
+ "Values": [
128
+ "Amazon Elastic Compute Cloud - Compute"
129
+ ],
130
+ "Key": "SERVICE",
131
+ "MatchOptions": [
132
+ "EQUALS"
133
+ ]
134
+ }
135
+ },
136
+ {
137
+ "Dimensions": {
138
+ "Values": [
139
+ "EC2: Running Hours"
140
+ ],
141
+ "Key": "USAGE_TYPE_GROUP",
142
+ "MatchOptions": [
143
+ "EQUALS"
144
+ ]
145
+ }
146
+ }
147
+ ]
148
+ },
149
+ "metric": "UsageQuantity",
150
+ "group_by": "USAGE_TYPE",
151
+ "granularity": "MONTHLY"
152
+ }
153
+
154
+ Args:
155
+ ctx: MCP context
156
+ date_range: The billing period start and end dates in YYYY-MM-DD format (end date is inclusive)
157
+ granularity: The granularity at which cost data is aggregated (DAILY, MONTHLY, HOURLY)
158
+ group_by: Either a dictionary with Type and Key, or simply a string key to group by
159
+ filter_expression: Filter criteria as a Python dictionary
160
+ metric: Cost metric to use (UnblendedCost, BlendedCost, etc.)
161
+
162
+ Returns:
163
+ Dictionary containing cost report data grouped according to the specified parameters
164
+ """
165
+ # Initialize variables at function scope to avoid unbound variable issues
166
+ billing_period_start = date_range.start_date
167
+ billing_period_end = date_range.end_date
168
+
169
+ try:
170
+ # Process inputs - simplified granularity validation
171
+ granularity = str(granularity).upper()
172
+
173
+ if granularity not in VALID_GRANULARITIES:
174
+ return {
175
+ 'error': f'Invalid granularity: {granularity}. Valid values are {", ".join(VALID_GRANULARITIES)}.'
176
+ }
177
+
178
+ # Validate date range with granularity-specific constraints
179
+ try:
180
+ date_range.validate_with_granularity(granularity)
181
+ except ValueError as e:
182
+ return {'error': str(e)}
183
+
184
+ # Define valid metrics and their expected data structure
185
+ valid_metrics = {
186
+ metric: {'has_unit': True, 'is_cost': metric != 'UsageQuantity'}
187
+ for metric in VALID_COST_METRICS
188
+ }
189
+
190
+ if metric not in VALID_COST_METRICS:
191
+ return {
192
+ 'error': f'Invalid metric: {metric}. Valid values are {", ".join(VALID_COST_METRICS)}.'
193
+ }
194
+
195
+ metric_config = valid_metrics[metric]
196
+
197
+ # Adjust end date for Cost Explorer API (exclusive)
198
+ # Add one day to make the end date inclusive for the user
199
+ billing_period_end_adj = (
200
+ datetime.strptime(billing_period_end, '%Y-%m-%d')
201
+ + timedelta(days=COST_EXPLORER_END_DATE_OFFSET)
202
+ ).strftime('%Y-%m-%d')
203
+
204
+ # Process filter
205
+ filter_criteria = filter_expression
206
+
207
+ # Validate filter expression if provided
208
+ if filter_criteria:
209
+ # This validates both structure and values against AWS Cost Explorer
210
+ validation_result = validate_expression(
211
+ filter_criteria, billing_period_start, billing_period_end_adj
212
+ )
213
+ if 'error' in validation_result:
214
+ return validation_result
215
+
216
+ # Process group_by
217
+ if group_by is None:
218
+ group_by = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
219
+ elif isinstance(group_by, str):
220
+ group_by = {'Type': 'DIMENSION', 'Key': group_by}
221
+
222
+ # Validate group_by using the existing validate_group_by function
223
+ validation_result = validate_group_by(group_by)
224
+ if 'error' in validation_result:
225
+ return validation_result
226
+
227
+ # Prepare API call parameters
228
+ common_params = {
229
+ 'TimePeriod': {
230
+ 'Start': format_date_for_api(billing_period_start, granularity),
231
+ 'End': format_date_for_api(billing_period_end_adj, granularity),
232
+ },
233
+ 'Granularity': granularity,
234
+ 'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
235
+ 'Metrics': [metric],
236
+ }
237
+
238
+ if filter_criteria:
239
+ common_params['Filter'] = filter_criteria
240
+
241
+ # Get cost data
242
+ grouped_costs = {}
243
+ next_token = None
244
+ ce = get_cost_explorer_client()
245
+
246
+ while True:
247
+ if next_token:
248
+ common_params['NextPageToken'] = next_token
249
+
250
+ try:
251
+ response = ce.get_cost_and_usage(**common_params)
252
+ except Exception as e:
253
+ logger.error(f'Error calling Cost Explorer API: {e}')
254
+ return {'error': f'AWS Cost Explorer API error: {str(e)}'}
255
+
256
+ for result_by_time in response['ResultsByTime']:
257
+ date = result_by_time['TimePeriod']['Start']
258
+ for group in result_by_time.get('Groups', []):
259
+ if not group.get('Keys') or len(group['Keys']) == 0:
260
+ logger.warning(f'Skipping group with no keys: {group}')
261
+ continue
262
+
263
+ group_key = group['Keys'][0]
264
+
265
+ # Validate that the metric exists in the response
266
+ if metric not in group.get('Metrics', {}):
267
+ logger.error(
268
+ f"Metric '{metric}' not found in response for group {group_key}"
269
+ )
270
+ return {
271
+ 'error': f"Metric '{metric}' not found in response for group {group_key}"
272
+ }
273
+
274
+ try:
275
+ metric_data = group['Metrics'][metric]
276
+
277
+ # Validate metric data structure
278
+ if 'Amount' not in metric_data:
279
+ logger.error(
280
+ f'Amount not found in metric data for {group_key}: {metric_data}'
281
+ )
282
+ return {
283
+ 'error': "Invalid response format: 'Amount' not found in metric data"
284
+ }
285
+
286
+ # Process based on metric type
287
+ if metric_config['is_cost']:
288
+ # Handle cost metrics
289
+ cost = float(metric_data['Amount'])
290
+ grouped_costs.setdefault(date, {}).update({group_key: cost})
291
+ else:
292
+ # Handle usage metrics (UsageQuantity, NormalizedUsageAmount)
293
+ if 'Unit' not in metric_data and metric_config['has_unit']:
294
+ logger.warning(
295
+ f"Unit not found in {metric} data for {group_key}, using 'Unknown' as unit"
296
+ )
297
+ unit = 'Unknown'
298
+ else:
299
+ unit = metric_data.get('Unit', 'Count')
300
+ amount = float(metric_data['Amount'])
301
+ grouped_costs.setdefault(date, {}).update({group_key: (amount, unit)})
302
+ except (ValueError, TypeError) as e:
303
+ logger.error(f'Error processing metric data: {e}, group: {group_key}')
304
+ return {'error': f'Error processing metric data: {str(e)}'}
305
+
306
+ next_token = response.get('NextPageToken')
307
+ if not next_token:
308
+ break
309
+
310
+ # Process results
311
+ if not grouped_costs:
312
+ logger.info(
313
+ f'No cost data found for the specified parameters: {billing_period_start} to {billing_period_end}'
314
+ )
315
+ return {
316
+ 'message': 'No cost data found for the specified parameters',
317
+ 'GroupedCosts': {},
318
+ }
319
+
320
+ try:
321
+ if metric_config['is_cost']:
322
+ # Process cost metrics
323
+ df = pd.DataFrame.from_dict(grouped_costs).round(2)
324
+
325
+ # Dynamic labeling based on group dimension
326
+ group_dimension = group_by['Key'].lower().replace('_', ' ')
327
+ df[f'{group_dimension.title()} Total'] = df.sum(axis=1).round(2)
328
+ df.loc[f'Total {metric}'] = df.sum().round(2)
329
+ df = df.sort_values(by=f'{group_dimension.title()} Total', ascending=False)
330
+
331
+ result = {'GroupedCosts': df.to_dict()}
332
+ else:
333
+ # Process usage metrics with cleaner structure
334
+ result_data = {}
335
+ for date, groups in grouped_costs.items():
336
+ result_data[date] = {}
337
+ for group_key, (amount, unit) in groups.items():
338
+ result_data[date][group_key] = {
339
+ 'amount': round(float(amount), 2),
340
+ 'unit': unit,
341
+ }
342
+
343
+ # Add metadata for usage metrics
344
+ result = {
345
+ 'metadata': {
346
+ 'grouped_by': group_by['Key'],
347
+ 'metric': metric,
348
+ 'period': f'{billing_period_start} to {billing_period_end}',
349
+ },
350
+ 'GroupedUsage': result_data,
351
+ }
352
+ except Exception as e:
353
+ logger.error(f'Error processing cost data into DataFrame: {e}')
354
+ return {
355
+ 'error': f'Error processing cost data: {str(e)}',
356
+ 'raw_data': grouped_costs,
357
+ }
358
+
359
+ # Test JSON serialization first, only stringify if needed
360
+ try:
361
+ json.dumps(result)
362
+ return result
363
+ except (TypeError, ValueError):
364
+ # Only stringify if JSON serialization fails
365
+ def stringify_keys(d: Any) -> Any:
366
+ if isinstance(d, dict):
367
+ return {str(k): stringify_keys(v) for k, v in d.items()}
368
+ elif isinstance(d, list):
369
+ return [stringify_keys(i) if i is not None else None for i in d]
370
+ else:
371
+ return d
372
+
373
+ try:
374
+ result = stringify_keys(result)
375
+ return result
376
+ except Exception as e:
377
+ logger.error(f'Error serializing result: {e}')
378
+ return {'error': f'Error serializing result: {str(e)}'}
379
+
380
+ except Exception as e:
381
+ logger.error(
382
+ f'Error generating cost report for period {billing_period_start} to {billing_period_end}: {e}'
383
+ )
384
+
385
+ return {'error': f'Error generating cost report: {str(e)}'}