awslabs.cost-explorer-mcp-server 0.0.1__py3-none-any.whl → 0.0.4__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/__init__.py +15 -1
- awslabs/cost_explorer_mcp_server/__init__.py +17 -3
- awslabs/cost_explorer_mcp_server/helpers.py +118 -61
- awslabs/cost_explorer_mcp_server/server.py +194 -107
- {awslabs_cost_explorer_mcp_server-0.0.1.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/METADATA +15 -18
- awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD +10 -0
- awslabs_cost_explorer_mcp_server-0.0.1.dist-info/RECORD +0 -10
- {awslabs_cost_explorer_mcp_server-0.0.1.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/WHEEL +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.1.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/entry_points.txt +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.1.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.1.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/licenses/NOTICE +0 -0
awslabs/__init__.py
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
1
15
|
"""
|
|
2
16
|
AWS Labs Cost Explorer MCP Server package.
|
|
3
|
-
"""
|
|
17
|
+
"""
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 module.
|
|
3
16
|
|
|
4
17
|
This module provides MCP tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
|
|
5
18
|
"""
|
|
6
|
-
|
|
19
|
+
|
|
20
|
+
__version__ = '0.0.0'
|
|
@@ -1,10 +1,25 @@
|
|
|
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
|
+
|
|
1
15
|
"""Helper functions for the Cost Explorer MCP server."""
|
|
2
16
|
|
|
17
|
+
import boto3
|
|
3
18
|
import logging
|
|
4
19
|
import re
|
|
5
|
-
from typing import Dict, Any, List, Tuple
|
|
6
|
-
import boto3
|
|
7
20
|
from datetime import datetime
|
|
21
|
+
from typing import Any, Dict, Optional, Tuple
|
|
22
|
+
|
|
8
23
|
|
|
9
24
|
# Set up logging
|
|
10
25
|
logger = logging.getLogger(__name__)
|
|
@@ -12,96 +27,101 @@ logger = logging.getLogger(__name__)
|
|
|
12
27
|
# Initialize AWS Cost Explorer client
|
|
13
28
|
ce = boto3.client('ce')
|
|
14
29
|
|
|
30
|
+
|
|
15
31
|
def validate_date_format(date_str: str) -> Tuple[bool, str]:
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
"""Validate that a date string is in YYYY-MM-DD format and is a valid date.
|
|
33
|
+
|
|
19
34
|
Args:
|
|
20
35
|
date_str: The date string to validate
|
|
21
|
-
|
|
36
|
+
|
|
22
37
|
Returns:
|
|
23
38
|
Tuple of (is_valid, error_message)
|
|
24
39
|
"""
|
|
25
40
|
# Check format with regex
|
|
26
41
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
27
42
|
return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
|
|
28
|
-
|
|
43
|
+
|
|
29
44
|
# Check if it's a valid date
|
|
30
45
|
try:
|
|
31
46
|
datetime.strptime(date_str, '%Y-%m-%d')
|
|
32
|
-
return True,
|
|
47
|
+
return True, ''
|
|
33
48
|
except ValueError as e:
|
|
34
49
|
return False, f"Invalid date '{date_str}': {str(e)}"
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
|
|
52
|
+
def get_dimension_values(
|
|
53
|
+
key: str, billing_period_start: str, billing_period_end: str
|
|
54
|
+
) -> Dict[str, Any]:
|
|
37
55
|
"""Get available values for a specific dimension."""
|
|
38
56
|
# Validate date formats
|
|
39
57
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
40
58
|
if not is_valid_start:
|
|
41
59
|
return {'error': error_start}
|
|
42
|
-
|
|
60
|
+
|
|
43
61
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
44
62
|
if not is_valid_end:
|
|
45
63
|
return {'error': error_end}
|
|
46
|
-
|
|
64
|
+
|
|
47
65
|
# Validate date range
|
|
48
66
|
if billing_period_start > billing_period_end:
|
|
49
|
-
return {
|
|
50
|
-
|
|
67
|
+
return {
|
|
68
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
try:
|
|
52
72
|
response = ce.get_dimension_values(
|
|
53
|
-
TimePeriod={
|
|
54
|
-
|
|
55
|
-
'End': billing_period_end
|
|
56
|
-
},
|
|
57
|
-
Dimension=key.upper()
|
|
73
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
74
|
+
Dimension=key.upper(),
|
|
58
75
|
)
|
|
59
76
|
dimension_values = response['DimensionValues']
|
|
60
77
|
values = [value['Value'] for value in dimension_values]
|
|
61
78
|
return {'dimension': key.upper(), 'values': values}
|
|
62
79
|
except Exception as e:
|
|
63
|
-
logger.error(f
|
|
80
|
+
logger.error(f'Error getting dimension values: {e}')
|
|
64
81
|
return {'error': str(e)}
|
|
65
82
|
|
|
66
83
|
|
|
67
|
-
def get_tag_values(
|
|
84
|
+
def get_tag_values(
|
|
85
|
+
tag_key: str, billing_period_start: str, billing_period_end: str
|
|
86
|
+
) -> Dict[str, Any]:
|
|
68
87
|
"""Get available values for a specific tag key."""
|
|
69
88
|
# Validate date formats
|
|
70
89
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
71
90
|
if not is_valid_start:
|
|
72
91
|
return {'error': error_start}
|
|
73
|
-
|
|
92
|
+
|
|
74
93
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
75
94
|
if not is_valid_end:
|
|
76
95
|
return {'error': error_end}
|
|
77
|
-
|
|
96
|
+
|
|
78
97
|
# Validate date range
|
|
79
98
|
if billing_period_start > billing_period_end:
|
|
80
|
-
return {
|
|
81
|
-
|
|
99
|
+
return {
|
|
100
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
try:
|
|
83
104
|
response = ce.get_tags(
|
|
84
|
-
TimePeriod={'Start': billing_period_start,
|
|
85
|
-
|
|
86
|
-
TagKey=tag_key
|
|
105
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
106
|
+
TagKey=tag_key,
|
|
87
107
|
)
|
|
88
108
|
tag_values = response['Tags']
|
|
89
109
|
return {'tag_key': tag_key, 'values': tag_values}
|
|
90
110
|
except Exception as e:
|
|
91
|
-
logger.error(f
|
|
111
|
+
logger.error(f'Error getting tag values: {e}')
|
|
92
112
|
return {'error': str(e)}
|
|
93
113
|
|
|
94
114
|
|
|
115
|
+
def validate_expression(
|
|
116
|
+
expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
"""Recursively validate the filter expression.
|
|
95
119
|
|
|
96
|
-
def validate_expression(expression: Dict[str, Any], billing_period_start: str, billing_period_end: str) -> Dict[str, Any]:
|
|
97
|
-
"""
|
|
98
|
-
Recursively validate the filter expression.
|
|
99
|
-
|
|
100
120
|
Args:
|
|
101
121
|
expression: The filter expression to validate
|
|
102
122
|
billing_period_start: Start date of the billing period
|
|
103
123
|
billing_period_end: End date of the billing period
|
|
104
|
-
|
|
124
|
+
|
|
105
125
|
Returns:
|
|
106
126
|
Empty dictionary if valid, or an error dictionary
|
|
107
127
|
"""
|
|
@@ -109,31 +129,42 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
|
|
|
109
129
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
110
130
|
if not is_valid_start:
|
|
111
131
|
return {'error': error_start}
|
|
112
|
-
|
|
132
|
+
|
|
113
133
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
114
134
|
if not is_valid_end:
|
|
115
135
|
return {'error': error_end}
|
|
116
|
-
|
|
136
|
+
|
|
117
137
|
# Validate date range
|
|
118
138
|
if billing_period_start > billing_period_end:
|
|
119
|
-
return {
|
|
120
|
-
|
|
139
|
+
return {
|
|
140
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
try:
|
|
122
144
|
if 'Dimensions' in expression:
|
|
123
145
|
dimension = expression['Dimensions']
|
|
124
|
-
if
|
|
125
|
-
|
|
146
|
+
if (
|
|
147
|
+
'Key' not in dimension
|
|
148
|
+
or 'Values' not in dimension
|
|
149
|
+
or 'MatchOptions' not in dimension
|
|
150
|
+
):
|
|
151
|
+
return {
|
|
152
|
+
'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
|
|
153
|
+
}
|
|
126
154
|
|
|
127
155
|
dimension_key = dimension['Key']
|
|
128
156
|
dimension_values = dimension['Values']
|
|
129
157
|
valid_values_response = get_dimension_values(
|
|
130
|
-
dimension_key, billing_period_start, billing_period_end
|
|
158
|
+
dimension_key, billing_period_start, billing_period_end
|
|
159
|
+
)
|
|
131
160
|
if 'error' in valid_values_response:
|
|
132
161
|
return {'error': valid_values_response['error']}
|
|
133
162
|
valid_values = valid_values_response['values']
|
|
134
163
|
for value in dimension_values:
|
|
135
164
|
if value not in valid_values:
|
|
136
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
|
|
167
|
+
}
|
|
137
168
|
|
|
138
169
|
if 'Tags' in expression:
|
|
139
170
|
tag = expression['Tags']
|
|
@@ -143,34 +174,48 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
|
|
|
143
174
|
tag_key = tag['Key']
|
|
144
175
|
tag_values = tag['Values']
|
|
145
176
|
valid_tag_values_response = get_tag_values(
|
|
146
|
-
tag_key, billing_period_start, billing_period_end
|
|
177
|
+
tag_key, billing_period_start, billing_period_end
|
|
178
|
+
)
|
|
147
179
|
if 'error' in valid_tag_values_response:
|
|
148
180
|
return {'error': valid_tag_values_response['error']}
|
|
149
181
|
valid_tag_values = valid_tag_values_response['values']
|
|
150
182
|
for value in tag_values:
|
|
151
183
|
if value not in valid_tag_values:
|
|
152
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
|
|
186
|
+
}
|
|
153
187
|
|
|
154
188
|
if 'CostCategories' in expression:
|
|
155
189
|
cost_category = expression['CostCategories']
|
|
156
|
-
if
|
|
157
|
-
|
|
190
|
+
if (
|
|
191
|
+
'Key' not in cost_category
|
|
192
|
+
or 'Values' not in cost_category
|
|
193
|
+
or 'MatchOptions' not in cost_category
|
|
194
|
+
):
|
|
195
|
+
return {
|
|
196
|
+
'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
|
|
197
|
+
}
|
|
158
198
|
|
|
159
199
|
logical_operators = ['And', 'Or', 'Not']
|
|
160
200
|
logical_count = sum(1 for op in logical_operators if op in expression)
|
|
161
201
|
|
|
162
202
|
if logical_count > 1:
|
|
163
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'
|
|
205
|
+
}
|
|
164
206
|
|
|
165
207
|
if logical_count == 0 and len(expression) > 1:
|
|
166
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
|
|
210
|
+
}
|
|
167
211
|
|
|
168
212
|
if 'And' in expression:
|
|
169
213
|
if not isinstance(expression['And'], list):
|
|
170
214
|
return {'error': 'And expression must be a list of expressions.'}
|
|
171
215
|
for sub_expression in expression['And']:
|
|
172
216
|
result = validate_expression(
|
|
173
|
-
sub_expression, billing_period_start, billing_period_end
|
|
217
|
+
sub_expression, billing_period_start, billing_period_end
|
|
218
|
+
)
|
|
174
219
|
if 'error' in result:
|
|
175
220
|
return result
|
|
176
221
|
|
|
@@ -179,7 +224,8 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
|
|
|
179
224
|
return {'error': 'Or expression must be a list of expressions.'}
|
|
180
225
|
for sub_expression in expression['Or']:
|
|
181
226
|
result = validate_expression(
|
|
182
|
-
sub_expression, billing_period_start, billing_period_end
|
|
227
|
+
sub_expression, billing_period_start, billing_period_end
|
|
228
|
+
)
|
|
183
229
|
if 'error' in result:
|
|
184
230
|
return result
|
|
185
231
|
|
|
@@ -187,35 +233,46 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
|
|
|
187
233
|
if not isinstance(expression['Not'], dict):
|
|
188
234
|
return {'error': 'Not expression must be a single expression.'}
|
|
189
235
|
result = validate_expression(
|
|
190
|
-
expression['Not'], billing_period_start, billing_period_end
|
|
236
|
+
expression['Not'], billing_period_start, billing_period_end
|
|
237
|
+
)
|
|
191
238
|
if 'error' in result:
|
|
192
239
|
return result
|
|
193
240
|
|
|
194
|
-
if not any(
|
|
195
|
-
|
|
241
|
+
if not any(
|
|
242
|
+
k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
|
|
243
|
+
):
|
|
244
|
+
return {
|
|
245
|
+
'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
|
|
246
|
+
}
|
|
196
247
|
|
|
197
248
|
return {}
|
|
198
249
|
except Exception as e:
|
|
199
250
|
return {'error': f'Error validating expression: {str(e)}'}
|
|
200
251
|
|
|
201
252
|
|
|
202
|
-
def validate_group_by(group_by: Dict[str, Any]) -> Dict[str, Any]:
|
|
203
|
-
"""
|
|
204
|
-
|
|
205
|
-
|
|
253
|
+
def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
254
|
+
"""Validate the group_by parameter.
|
|
255
|
+
|
|
206
256
|
Args:
|
|
207
257
|
group_by: The group_by dictionary to validate
|
|
208
|
-
|
|
258
|
+
|
|
209
259
|
Returns:
|
|
210
260
|
Empty dictionary if valid, or an error dictionary
|
|
211
261
|
"""
|
|
212
262
|
try:
|
|
213
|
-
if
|
|
263
|
+
if (
|
|
264
|
+
group_by is None
|
|
265
|
+
or not isinstance(group_by, dict)
|
|
266
|
+
or 'Type' not in group_by
|
|
267
|
+
or 'Key' not in group_by
|
|
268
|
+
):
|
|
214
269
|
return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
|
|
215
|
-
|
|
270
|
+
|
|
216
271
|
if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
|
|
217
|
-
return {
|
|
218
|
-
|
|
272
|
+
return {
|
|
273
|
+
'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
|
|
274
|
+
}
|
|
275
|
+
|
|
219
276
|
return {}
|
|
220
277
|
except Exception as e:
|
|
221
278
|
return {'error': f'Error validating group_by: {str(e)}'}
|
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
1
15
|
"""Cost Explorer MCP server implementation.
|
|
2
16
|
|
|
3
17
|
This server provides tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
|
|
@@ -5,19 +19,19 @@ This server provides tools for analyzing AWS costs and usage data through the AW
|
|
|
5
19
|
|
|
6
20
|
import boto3
|
|
7
21
|
import logging
|
|
8
|
-
from datetime import datetime, timedelta
|
|
9
22
|
import pandas as pd
|
|
10
|
-
from mcp.server.fastmcp import Context, FastMCP
|
|
11
|
-
from pydantic import BaseModel, Field, field_validator
|
|
12
|
-
from typing import Any, Dict, Optional, Union
|
|
13
|
-
|
|
14
23
|
from awslabs.cost_explorer_mcp_server.helpers import (
|
|
15
24
|
get_dimension_values,
|
|
16
25
|
get_tag_values,
|
|
26
|
+
validate_date_format,
|
|
17
27
|
validate_expression,
|
|
18
28
|
validate_group_by,
|
|
19
|
-
validate_date_format
|
|
20
29
|
)
|
|
30
|
+
from datetime import datetime, timedelta
|
|
31
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
32
|
+
from pydantic import BaseModel, Field, field_validator
|
|
33
|
+
from typing import Any, Dict, Optional, Union
|
|
34
|
+
|
|
21
35
|
|
|
22
36
|
# Set up logging
|
|
23
37
|
logging.basicConfig(level=logging.INFO)
|
|
@@ -29,99 +43,106 @@ ce = boto3.client('ce')
|
|
|
29
43
|
|
|
30
44
|
class DateRange(BaseModel):
|
|
31
45
|
"""Date range model for cost queries."""
|
|
46
|
+
|
|
32
47
|
start_date: str = Field(
|
|
33
48
|
...,
|
|
34
|
-
description=
|
|
49
|
+
description='The start date of the billing period in YYYY-MM-DD format. Defaults to last month, if not provided.',
|
|
35
50
|
)
|
|
36
51
|
end_date: str = Field(
|
|
37
|
-
...,
|
|
38
|
-
description="The end date of the billing period in YYYY-MM-DD format."
|
|
52
|
+
..., description='The end date of the billing period in YYYY-MM-DD format.'
|
|
39
53
|
)
|
|
40
|
-
|
|
54
|
+
|
|
41
55
|
@field_validator('start_date')
|
|
42
56
|
@classmethod
|
|
43
57
|
def validate_start_date(cls, v):
|
|
58
|
+
"""Validate that start_date is in YYYY-MM-DD format and is a valid date."""
|
|
44
59
|
is_valid, error = validate_date_format(v)
|
|
45
60
|
if not is_valid:
|
|
46
61
|
raise ValueError(error)
|
|
47
62
|
return v
|
|
48
|
-
|
|
63
|
+
|
|
49
64
|
@field_validator('end_date')
|
|
50
65
|
@classmethod
|
|
51
66
|
def validate_end_date(cls, v, info):
|
|
67
|
+
"""Validate that end_date is in YYYY-MM-DD format and is a valid date, and not before start_date."""
|
|
52
68
|
is_valid, error = validate_date_format(v)
|
|
53
69
|
if not is_valid:
|
|
54
70
|
raise ValueError(error)
|
|
55
|
-
|
|
71
|
+
|
|
56
72
|
# Access the start_date from the data dictionary
|
|
57
73
|
start_date = info.data.get('start_date')
|
|
58
74
|
if start_date and v < start_date:
|
|
59
75
|
raise ValueError(f"End date '{v}' cannot be before start date '{start_date}'")
|
|
60
|
-
|
|
76
|
+
|
|
61
77
|
return v
|
|
62
78
|
|
|
63
79
|
|
|
64
80
|
class GroupBy(BaseModel):
|
|
65
81
|
"""Group by model for cost queries."""
|
|
82
|
+
|
|
66
83
|
type: str = Field(
|
|
67
84
|
...,
|
|
68
|
-
description=
|
|
85
|
+
description='Type of grouping. Valid values are DIMENSION, TAG, and COST_CATEGORY.',
|
|
69
86
|
)
|
|
70
87
|
key: str = Field(
|
|
71
88
|
...,
|
|
72
|
-
description=
|
|
89
|
+
description='Key to group by. For DIMENSION type, valid values include AZ, INSTANCE_TYPE, LEGAL_ENTITY_NAME, INVOICING_ENTITY, LINKED_ACCOUNT, OPERATION, PLATFORM, PURCHASE_TYPE, SERVICE, TENANCY, RECORD_TYPE, and USAGE_TYPE.',
|
|
73
90
|
)
|
|
74
91
|
|
|
75
92
|
|
|
76
93
|
class FilterExpression(BaseModel):
|
|
77
94
|
"""Filter expression model for cost queries."""
|
|
95
|
+
|
|
78
96
|
filter_json: str = Field(
|
|
79
97
|
...,
|
|
80
|
-
description="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. 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']}}]}."
|
|
98
|
+
description="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. 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']}}]}.",
|
|
81
99
|
)
|
|
82
100
|
|
|
83
101
|
|
|
84
102
|
class CostMetric(BaseModel):
|
|
85
103
|
"""Cost metric model."""
|
|
104
|
+
|
|
86
105
|
metric: str = Field(
|
|
87
|
-
|
|
88
|
-
description=
|
|
106
|
+
'UnblendedCost',
|
|
107
|
+
description='The metric to return in the query. Valid values are AmortizedCost, BlendedCost, NetAmortizedCost, NetUnblendedCost, NormalizedUsageAmount, UnblendedCost, and UsageQuantity. Note: For UsageQuantity, the service aggregates usage numbers without considering units. To get meaningful UsageQuantity metrics, filter by UsageType or UsageTypeGroups.',
|
|
89
108
|
)
|
|
90
109
|
|
|
91
110
|
|
|
92
111
|
class DimensionKey(BaseModel):
|
|
93
112
|
"""Dimension key model."""
|
|
113
|
+
|
|
94
114
|
dimension_key: str = Field(
|
|
95
115
|
...,
|
|
96
|
-
description=
|
|
116
|
+
description='The name of the dimension to retrieve values for. Valid values are AZ, INSTANCE_TYPE, LINKED_ACCOUNT, OPERATION, PURCHASE_TYPE, SERVICE, USAGE_TYPE, PLATFORM, TENANCY, RECORD_TYPE, LEGAL_ENTITY_NAME, INVOICING_ENTITY, DEPLOYMENT_OPTION, DATABASE_ENGINE, CACHE_ENGINE, INSTANCE_TYPE_FAMILY, REGION, BILLING_ENTITY, RESERVATION_ID, SAVINGS_PLANS_TYPE, SAVINGS_PLAN_ARN, OPERATING_SYSTEM.',
|
|
97
117
|
)
|
|
98
118
|
|
|
99
119
|
|
|
100
120
|
# Create FastMCP server
|
|
101
|
-
app = FastMCP(title=
|
|
121
|
+
app = FastMCP(title='Cost Explorer MCP Server')
|
|
102
122
|
|
|
103
123
|
|
|
104
|
-
@app.tool(
|
|
124
|
+
@app.tool('get_today_date')
|
|
105
125
|
async def get_today_date(ctx: Context) -> Dict[str, str]:
|
|
106
126
|
"""Retrieve current date information.
|
|
107
127
|
|
|
108
128
|
This tool retrieves the current date in YYYY-MM-DD format and the current month in YYYY-MM format.
|
|
109
129
|
It's useful for comparing if the billing period requested by the user is not in the future.
|
|
110
130
|
|
|
131
|
+
Args:
|
|
132
|
+
ctx: MCP context
|
|
133
|
+
|
|
111
134
|
Returns:
|
|
112
135
|
Dictionary containing today's date and current month
|
|
113
136
|
"""
|
|
114
137
|
return {
|
|
115
138
|
'today_date': datetime.now().strftime('%Y-%m-%d'),
|
|
116
|
-
'current_month': datetime.now().strftime('%Y-%m')
|
|
139
|
+
'current_month': datetime.now().strftime('%Y-%m'),
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
|
|
120
|
-
@app.tool(
|
|
143
|
+
@app.tool('get_dimension_values')
|
|
121
144
|
async def get_dimension_values_tool(
|
|
122
|
-
ctx: Context,
|
|
123
|
-
date_range: DateRange,
|
|
124
|
-
dimension: DimensionKey
|
|
145
|
+
ctx: Context, date_range: DateRange, dimension: DimensionKey
|
|
125
146
|
) -> Dict[str, Any]:
|
|
126
147
|
"""Retrieve available dimension values for AWS Cost Explorer.
|
|
127
148
|
|
|
@@ -130,6 +151,7 @@ async def get_dimension_values_tool(
|
|
|
130
151
|
for cost analysis.
|
|
131
152
|
|
|
132
153
|
Args:
|
|
154
|
+
ctx: MCP context
|
|
133
155
|
date_range: The billing period start and end dates in YYYY-MM-DD format
|
|
134
156
|
dimension: The dimension key to retrieve values for (e.g., SERVICE, REGION, LINKED_ACCOUNT)
|
|
135
157
|
|
|
@@ -138,21 +160,19 @@ async def get_dimension_values_tool(
|
|
|
138
160
|
"""
|
|
139
161
|
try:
|
|
140
162
|
response = get_dimension_values(
|
|
141
|
-
dimension.dimension_key,
|
|
142
|
-
date_range.start_date,
|
|
143
|
-
date_range.end_date
|
|
163
|
+
dimension.dimension_key, date_range.start_date, date_range.end_date
|
|
144
164
|
)
|
|
145
165
|
return response
|
|
146
166
|
except Exception as e:
|
|
147
|
-
logger.error(f
|
|
167
|
+
logger.error(f'Error getting dimension values: {e}')
|
|
148
168
|
return {'error': f'Error getting dimension values: {str(e)}'}
|
|
149
169
|
|
|
150
170
|
|
|
151
|
-
@app.tool(
|
|
171
|
+
@app.tool('get_tag_values')
|
|
152
172
|
async def get_tag_values_tool(
|
|
153
173
|
ctx: Context,
|
|
154
174
|
date_range: DateRange,
|
|
155
|
-
tag_key: str = Field(..., description=
|
|
175
|
+
tag_key: str = Field(..., description='The tag key to retrieve values for'),
|
|
156
176
|
) -> Dict[str, Any]:
|
|
157
177
|
"""Retrieve available tag values for AWS Cost Explorer.
|
|
158
178
|
|
|
@@ -160,6 +180,7 @@ async def get_tag_values_tool(
|
|
|
160
180
|
This is useful for validating tag filter values or exploring available tag options for cost analysis.
|
|
161
181
|
|
|
162
182
|
Args:
|
|
183
|
+
ctx: MCP context
|
|
163
184
|
date_range: The billing period start and end dates in YYYY-MM-DD format
|
|
164
185
|
tag_key: The tag key to retrieve values for
|
|
165
186
|
|
|
@@ -167,37 +188,33 @@ async def get_tag_values_tool(
|
|
|
167
188
|
Dictionary containing the tag key and list of available values
|
|
168
189
|
"""
|
|
169
190
|
try:
|
|
170
|
-
response = get_tag_values(
|
|
171
|
-
tag_key,
|
|
172
|
-
date_range.start_date,
|
|
173
|
-
date_range.end_date
|
|
174
|
-
)
|
|
191
|
+
response = get_tag_values(tag_key, date_range.start_date, date_range.end_date)
|
|
175
192
|
return response
|
|
176
193
|
except Exception as e:
|
|
177
|
-
logger.error(f
|
|
194
|
+
logger.error(f'Error getting tag values: {e}')
|
|
178
195
|
return {'error': f'Error getting tag values: {str(e)}'}
|
|
179
196
|
|
|
180
197
|
|
|
181
|
-
@app.tool(
|
|
198
|
+
@app.tool('get_cost_and_usage')
|
|
182
199
|
async def get_cost_and_usage(
|
|
183
200
|
ctx: Context,
|
|
184
201
|
date_range: DateRange,
|
|
185
202
|
granularity: str = Field(
|
|
186
|
-
|
|
187
|
-
description=
|
|
203
|
+
'MONTHLY',
|
|
204
|
+
description='The granularity at which cost data is aggregated. Valid values are DAILY, MONTHLY, and HOURLY. If not provided, defaults to MONTHLY.',
|
|
188
205
|
),
|
|
189
206
|
group_by: Optional[Union[Dict[str, str], str]] = Field(
|
|
190
207
|
None,
|
|
191
|
-
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'."
|
|
208
|
+
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'.",
|
|
192
209
|
),
|
|
193
210
|
filter_expression: Optional[Dict[str, Any]] = Field(
|
|
194
211
|
None,
|
|
195
|
-
description="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. 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']}}]}."
|
|
212
|
+
description="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. 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']}}]}.",
|
|
196
213
|
),
|
|
197
214
|
metric: str = Field(
|
|
198
|
-
|
|
199
|
-
description=
|
|
200
|
-
)
|
|
215
|
+
'UnblendedCost',
|
|
216
|
+
description='The metric to return in the query. Valid values are AmortizedCost, BlendedCost, NetAmortizedCost, NetUnblendedCost, NormalizedUsageAmount, UnblendedCost, and UsageQuantity.',
|
|
217
|
+
),
|
|
201
218
|
) -> Dict[str, Any]:
|
|
202
219
|
"""Retrieve AWS cost and usage data.
|
|
203
220
|
|
|
@@ -205,12 +222,42 @@ async def get_cost_and_usage(
|
|
|
205
222
|
with optional filtering and grouping. It dynamically generates cost reports tailored to specific needs
|
|
206
223
|
by specifying parameters such as granularity, billing period dates, and filter criteria.
|
|
207
224
|
|
|
208
|
-
Note: The end_date is treated as inclusive in this tool, meaning if you specify an end_date of
|
|
209
|
-
"2025-01-31", the results will include data for January 31st. This differs from the AWS Cost Explorer
|
|
225
|
+
Note: The end_date is treated as inclusive in this tool, meaning if you specify an end_date of
|
|
226
|
+
"2025-01-31", the results will include data for January 31st. This differs from the AWS Cost Explorer
|
|
210
227
|
API which treats end_date as exclusive.
|
|
211
228
|
|
|
229
|
+
Example: Get monthly costs for EC2 and S3 services in us-east-1 for May 2025
|
|
230
|
+
await get_cost_and_usage(
|
|
231
|
+
ctx=context,
|
|
232
|
+
date_range={
|
|
233
|
+
"start_date": "2025-05-01",
|
|
234
|
+
"end_date": "2025-05-31"
|
|
235
|
+
},
|
|
236
|
+
granularity="MONTHLY",
|
|
237
|
+
group_by={"Type": "DIMENSION", "Key": "SERVICE"},
|
|
238
|
+
filter_expression={
|
|
239
|
+
"And": [
|
|
240
|
+
{
|
|
241
|
+
"Dimensions": {
|
|
242
|
+
"Key": "SERVICE",
|
|
243
|
+
"Values": ["Amazon Elastic Compute Cloud - Compute", "Amazon Simple Storage Service"],
|
|
244
|
+
"MatchOptions": ["EQUALS"]
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"Dimensions": {
|
|
249
|
+
"Key": "REGION",
|
|
250
|
+
"Values": ["us-east-1"],
|
|
251
|
+
"MatchOptions": ["EQUALS"]
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
metric="UnblendedCost"
|
|
257
|
+
)
|
|
212
258
|
|
|
213
259
|
Args:
|
|
260
|
+
ctx: MCP context
|
|
214
261
|
date_range: The billing period start and end dates in YYYY-MM-DD format (end date is inclusive)
|
|
215
262
|
granularity: The granularity at which cost data is aggregated (DAILY, MONTHLY, HOURLY)
|
|
216
263
|
group_by: Either a dictionary with Type and Key, or simply a string key to group by
|
|
@@ -220,36 +267,44 @@ async def get_cost_and_usage(
|
|
|
220
267
|
Returns:
|
|
221
268
|
Dictionary containing cost report data grouped according to the specified parameters
|
|
222
269
|
"""
|
|
223
|
-
|
|
224
270
|
try:
|
|
225
271
|
# Process inputs
|
|
226
|
-
granularity
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
if isinstance(granularity, str):
|
|
273
|
+
granularity = granularity.upper()
|
|
274
|
+
else:
|
|
275
|
+
# Handle case where granularity is a Pydantic FieldInfo object
|
|
276
|
+
granularity = str(granularity).upper()
|
|
277
|
+
|
|
278
|
+
if granularity not in ['DAILY', 'MONTHLY', 'HOURLY']:
|
|
279
|
+
return {
|
|
280
|
+
'error': f'Invalid granularity: {granularity}. Valid values are DAILY, MONTHLY, and HOURLY.'
|
|
281
|
+
}
|
|
282
|
+
|
|
230
283
|
billing_period_start = date_range.start_date
|
|
231
284
|
billing_period_end = date_range.end_date
|
|
232
|
-
|
|
285
|
+
|
|
233
286
|
# Define valid metrics and their expected data structure
|
|
234
287
|
valid_metrics = {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
"UsageQuantity": {"has_unit": True, "is_cost": False}
|
|
288
|
+
'AmortizedCost': {'has_unit': True, 'is_cost': True},
|
|
289
|
+
'BlendedCost': {'has_unit': True, 'is_cost': True},
|
|
290
|
+
'NetAmortizedCost': {'has_unit': True, 'is_cost': True},
|
|
291
|
+
'NetUnblendedCost': {'has_unit': True, 'is_cost': True},
|
|
292
|
+
'UnblendedCost': {'has_unit': True, 'is_cost': True},
|
|
293
|
+
'UsageQuantity': {'has_unit': True, 'is_cost': False},
|
|
242
294
|
}
|
|
243
|
-
|
|
295
|
+
|
|
244
296
|
if metric not in valid_metrics:
|
|
245
|
-
return {
|
|
246
|
-
|
|
297
|
+
return {
|
|
298
|
+
'error': f'Invalid metric: {metric}. Valid values are {", ".join(valid_metrics.keys())}.'
|
|
299
|
+
}
|
|
300
|
+
|
|
247
301
|
metric_config = valid_metrics[metric]
|
|
248
302
|
|
|
249
303
|
# Adjust end date for Cost Explorer API (exclusive)
|
|
250
304
|
# Add one day to make the end date inclusive for the user
|
|
251
|
-
billing_period_end_adj = (
|
|
252
|
-
billing_period_end, '%Y-%m-%d') + timedelta(days=1)
|
|
305
|
+
billing_period_end_adj = (
|
|
306
|
+
datetime.strptime(billing_period_end, '%Y-%m-%d') + timedelta(days=1)
|
|
307
|
+
).strftime('%Y-%m-%d')
|
|
253
308
|
|
|
254
309
|
# Process filter
|
|
255
310
|
filter_criteria = filter_expression
|
|
@@ -258,15 +313,16 @@ async def get_cost_and_usage(
|
|
|
258
313
|
if filter_criteria:
|
|
259
314
|
# This validates both structure and values against AWS Cost Explorer
|
|
260
315
|
validation_result = validate_expression(
|
|
261
|
-
filter_criteria, billing_period_start, billing_period_end_adj
|
|
316
|
+
filter_criteria, billing_period_start, billing_period_end_adj
|
|
317
|
+
)
|
|
262
318
|
if 'error' in validation_result:
|
|
263
319
|
return validation_result
|
|
264
320
|
|
|
265
321
|
# Process group_by
|
|
266
322
|
if not group_by:
|
|
267
|
-
group_by = {
|
|
323
|
+
group_by = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
|
|
268
324
|
elif isinstance(group_by, str):
|
|
269
|
-
group_by = {
|
|
325
|
+
group_by = {'Type': 'DIMENSION', 'Key': group_by}
|
|
270
326
|
|
|
271
327
|
# Validate group_by using the existing validate_group_by function
|
|
272
328
|
validation_result = validate_group_by(group_by)
|
|
@@ -277,11 +333,11 @@ async def get_cost_and_usage(
|
|
|
277
333
|
common_params = {
|
|
278
334
|
'TimePeriod': {
|
|
279
335
|
'Start': billing_period_start,
|
|
280
|
-
'End': billing_period_end_adj
|
|
336
|
+
'End': billing_period_end_adj,
|
|
281
337
|
},
|
|
282
338
|
'Granularity': granularity,
|
|
283
339
|
'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
|
|
284
|
-
'Metrics': [metric]
|
|
340
|
+
'Metrics': [metric],
|
|
285
341
|
}
|
|
286
342
|
|
|
287
343
|
if filter_criteria:
|
|
@@ -297,55 +353,69 @@ async def get_cost_and_usage(
|
|
|
297
353
|
try:
|
|
298
354
|
response = ce.get_cost_and_usage(**common_params)
|
|
299
355
|
except Exception as e:
|
|
300
|
-
logger.error(f
|
|
356
|
+
logger.error(f'Error calling Cost Explorer API: {e}')
|
|
301
357
|
return {'error': f'AWS Cost Explorer API error: {str(e)}'}
|
|
302
358
|
|
|
303
359
|
for result_by_time in response['ResultsByTime']:
|
|
304
360
|
date = result_by_time['TimePeriod']['Start']
|
|
305
361
|
for group in result_by_time.get('Groups', []):
|
|
306
362
|
if not group.get('Keys') or len(group['Keys']) == 0:
|
|
307
|
-
logger.warning(f
|
|
363
|
+
logger.warning(f'Skipping group with no keys: {group}')
|
|
308
364
|
continue
|
|
309
|
-
|
|
365
|
+
|
|
310
366
|
group_key = group['Keys'][0]
|
|
311
|
-
|
|
367
|
+
|
|
312
368
|
# Validate that the metric exists in the response
|
|
313
369
|
if metric not in group.get('Metrics', {}):
|
|
314
|
-
logger.error(
|
|
315
|
-
|
|
316
|
-
|
|
370
|
+
logger.error(
|
|
371
|
+
f"Metric '{metric}' not found in response for group {group_key}"
|
|
372
|
+
)
|
|
373
|
+
return {
|
|
374
|
+
'error': f"Metric '{metric}' not found in response for group {group_key}"
|
|
375
|
+
}
|
|
376
|
+
|
|
317
377
|
metric_data = group['Metrics'][metric]
|
|
318
|
-
|
|
378
|
+
|
|
319
379
|
# Validate metric data structure
|
|
320
380
|
if 'Amount' not in metric_data:
|
|
321
|
-
logger.error(
|
|
322
|
-
|
|
323
|
-
|
|
381
|
+
logger.error(
|
|
382
|
+
f'Amount not found in metric data for {group_key}: {metric_data}'
|
|
383
|
+
)
|
|
384
|
+
return {
|
|
385
|
+
'error': "Invalid response format: 'Amount' not found in metric data"
|
|
386
|
+
}
|
|
387
|
+
|
|
324
388
|
try:
|
|
325
389
|
metric_data = group['Metrics'][metric]
|
|
326
|
-
|
|
390
|
+
|
|
327
391
|
# Validate metric data structure
|
|
328
392
|
if 'Amount' not in metric_data:
|
|
329
|
-
logger.error(
|
|
330
|
-
|
|
331
|
-
|
|
393
|
+
logger.error(
|
|
394
|
+
f'Amount not found in metric data for {group_key}: {metric_data}'
|
|
395
|
+
)
|
|
396
|
+
return {
|
|
397
|
+
'error': "Invalid response format: 'Amount' not found in metric data"
|
|
398
|
+
}
|
|
399
|
+
|
|
332
400
|
# Process based on metric type
|
|
333
|
-
if metric_config[
|
|
401
|
+
if metric_config['is_cost']:
|
|
334
402
|
# Handle cost metrics
|
|
335
403
|
cost = float(metric_data['Amount'])
|
|
336
404
|
grouped_costs.setdefault(date, {}).update({group_key: cost})
|
|
337
405
|
else:
|
|
338
406
|
# Handle usage metrics (UsageQuantity, NormalizedUsageAmount)
|
|
339
|
-
if 'Unit' not in metric_data and metric_config[
|
|
340
|
-
logger.warning(
|
|
407
|
+
if 'Unit' not in metric_data and metric_config['has_unit']:
|
|
408
|
+
logger.warning(
|
|
409
|
+
f"Unit not found in {metric} data for {group_key}, using 'Unknown' as unit"
|
|
410
|
+
)
|
|
341
411
|
unit = 'Unknown'
|
|
342
412
|
else:
|
|
343
413
|
unit = metric_data.get('Unit', 'Count')
|
|
344
414
|
amount = float(metric_data['Amount'])
|
|
345
415
|
grouped_costs.setdefault(date, {}).update({group_key: (amount, unit)})
|
|
346
416
|
except (ValueError, TypeError) as e:
|
|
347
|
-
logger.error(f
|
|
348
|
-
return {'error': f
|
|
417
|
+
logger.error(f'Error processing metric data: {e}, data: {metric_data}')
|
|
418
|
+
return {'error': f'Error processing metric data: {str(e)}'}
|
|
349
419
|
|
|
350
420
|
next_token = response.get('NextPageToken')
|
|
351
421
|
if not next_token:
|
|
@@ -353,11 +423,14 @@ async def get_cost_and_usage(
|
|
|
353
423
|
|
|
354
424
|
# Process results
|
|
355
425
|
if not grouped_costs:
|
|
356
|
-
logger.info(
|
|
357
|
-
return {
|
|
358
|
-
|
|
426
|
+
logger.info('No cost data found for the specified parameters')
|
|
427
|
+
return {
|
|
428
|
+
'message': 'No cost data found for the specified parameters',
|
|
429
|
+
'GroupedCosts': {},
|
|
430
|
+
}
|
|
431
|
+
|
|
359
432
|
try:
|
|
360
|
-
if metric_config[
|
|
433
|
+
if metric_config['is_cost']:
|
|
361
434
|
# Process cost metrics
|
|
362
435
|
df = pd.DataFrame.from_dict(grouped_costs).round(2)
|
|
363
436
|
df['Service total'] = df.sum(axis=1).round(2)
|
|
@@ -365,25 +438,36 @@ async def get_cost_and_usage(
|
|
|
365
438
|
df = df.sort_values(by='Service total', ascending=False)
|
|
366
439
|
else:
|
|
367
440
|
# Process usage metrics (UsageQuantity, NormalizedUsageAmount)
|
|
368
|
-
usage_df = pd.DataFrame(
|
|
369
|
-
|
|
441
|
+
usage_df = pd.DataFrame(
|
|
442
|
+
{
|
|
443
|
+
(k, 'Amount'): {k1: v1[0] for k1, v1 in v.items()}
|
|
444
|
+
for k, v in grouped_costs.items()
|
|
445
|
+
}
|
|
446
|
+
)
|
|
370
447
|
units_df = pd.DataFrame(
|
|
371
|
-
{
|
|
448
|
+
{
|
|
449
|
+
(k, 'Unit'): {k1: v1[1] for k1, v1 in v.items()}
|
|
450
|
+
for k, v in grouped_costs.items()
|
|
451
|
+
}
|
|
452
|
+
)
|
|
372
453
|
df = pd.concat([usage_df, units_df], axis=1)
|
|
373
454
|
|
|
374
455
|
result = {'GroupedCosts': df.to_dict()}
|
|
375
456
|
except Exception as e:
|
|
376
|
-
logger.error(f
|
|
377
|
-
return {
|
|
457
|
+
logger.error(f'Error processing cost data into DataFrame: {e}')
|
|
458
|
+
return {
|
|
459
|
+
'error': f'Error processing cost data: {str(e)}',
|
|
460
|
+
'raw_data': grouped_costs,
|
|
461
|
+
}
|
|
378
462
|
|
|
379
463
|
result = {'GroupedCosts': df.to_dict()}
|
|
380
464
|
|
|
381
465
|
# Convert all keys to strings for JSON serialization
|
|
382
|
-
def stringify_keys(d):
|
|
466
|
+
def stringify_keys(d: Any) -> Any:
|
|
383
467
|
if isinstance(d, dict):
|
|
384
468
|
return {str(k): stringify_keys(v) for k, v in d.items()}
|
|
385
469
|
elif isinstance(d, list):
|
|
386
|
-
return [stringify_keys(i) for i in d]
|
|
470
|
+
return [{} if i is None else stringify_keys(i) for i in d] # Handle None values
|
|
387
471
|
else:
|
|
388
472
|
return d
|
|
389
473
|
|
|
@@ -391,18 +475,21 @@ async def get_cost_and_usage(
|
|
|
391
475
|
result = stringify_keys(result)
|
|
392
476
|
return result
|
|
393
477
|
except Exception as e:
|
|
394
|
-
logger.error(f
|
|
395
|
-
return {'error': f
|
|
478
|
+
logger.error(f'Error serializing result: {e}')
|
|
479
|
+
return {'error': f'Error serializing result: {str(e)}'}
|
|
396
480
|
|
|
397
481
|
except Exception as e:
|
|
398
|
-
logger.error(f
|
|
482
|
+
logger.error(f'Error generating cost report: {e}')
|
|
399
483
|
import traceback
|
|
400
|
-
|
|
484
|
+
|
|
485
|
+
logger.error(f'Traceback: {traceback.format_exc()}')
|
|
401
486
|
return {'error': f'Error generating cost report: {str(e)}'}
|
|
402
487
|
|
|
488
|
+
|
|
403
489
|
def main():
|
|
404
490
|
"""Run the MCP server with CLI argument support."""
|
|
405
491
|
app.run()
|
|
406
492
|
|
|
407
|
-
|
|
493
|
+
|
|
494
|
+
if __name__ == '__main__':
|
|
408
495
|
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awslabs.cost-explorer-mcp-server
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer API
|
|
5
5
|
Project-URL: Homepage, https://awslabs.github.io/mcp/
|
|
6
6
|
Project-URL: Documentation, https://awslabs.github.io/mcp/servers/cost-explorer-mcp-server/
|
|
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: boto3>=1.36.20
|
|
25
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: mcp[cli]>=1.6.0
|
|
26
26
|
Requires-Dist: pandas>=2.2.3
|
|
27
27
|
Requires-Dist: pydantic>=2.10.6
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
@@ -127,6 +127,18 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
Make sure the AWS profile has permissions to access the AWS Cost Explorer API. The MCP server creates a boto3 session using the specified profile to authenticate with AWS services. Your AWS IAM credentials remain on your local machine and are strictly used for accessing AWS services.
|
|
130
|
+
|
|
131
|
+
## Cost Considerations
|
|
132
|
+
|
|
133
|
+
**Important:** AWS Cost Explorer API incurs charges on a per-request basis. Each API call made by this MCP server will result in charges to your AWS account.
|
|
134
|
+
|
|
135
|
+
- **Cost Explorer API Pricing:** The AWS Cost Explorer API lets you directly access the interactive, ad-hoc query engine that powers AWS Cost Explorer. Each request will incur a cost of $0.01.
|
|
136
|
+
- Each tool invocation that queries Cost Explorer (get_dimension_values, get_tag_values, get_cost_and_usage) will generate at least one billable API request
|
|
137
|
+
- Complex queries with multiple filters or large date ranges may result in multiple API calls
|
|
138
|
+
|
|
139
|
+
For current pricing information, please refer to the [AWS Cost Explorer Pricing page](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
|
|
140
|
+
|
|
141
|
+
|
|
130
142
|
## Security Considerations
|
|
131
143
|
|
|
132
144
|
### Required IAM Permissions
|
|
@@ -135,22 +147,7 @@ The following IAM permissions are required for this MCP server:
|
|
|
135
147
|
- ce:GetDimensionValues
|
|
136
148
|
- ce:GetTags
|
|
137
149
|
|
|
138
|
-
|
|
139
|
-
json
|
|
140
|
-
{
|
|
141
|
-
"Version": "2012-10-17",
|
|
142
|
-
"Statement": [
|
|
143
|
-
{
|
|
144
|
-
"Effect": "Allow",
|
|
145
|
-
"Action": [
|
|
146
|
-
"ce:GetCostAndUsage",
|
|
147
|
-
"ce:GetDimensionValues",
|
|
148
|
-
"ce:GetTags"
|
|
149
|
-
],
|
|
150
|
-
"Resource": "*"
|
|
151
|
-
}
|
|
152
|
-
]
|
|
153
|
-
}
|
|
150
|
+
|
|
154
151
|
|
|
155
152
|
## Available Tools
|
|
156
153
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
awslabs/__init__.py,sha256=XlNvbbm4JS0QaAK93MUCbMITZLOSkWkBilYvLI3rBpU,667
|
|
2
|
+
awslabs/cost_explorer_mcp_server/__init__.py,sha256=jj08M9QRfjYVfiV85UhDzpEO4Vseafpeekg31d2DhfM,785
|
|
3
|
+
awslabs/cost_explorer_mcp_server/helpers.py,sha256=8ldRc2TVFuE7-0Js4nQWw3v3e3Om48QgQgbTAXOecgI,10186
|
|
4
|
+
awslabs/cost_explorer_mcp_server/server.py,sha256=joYtlqmNnjGm162Qe71sz_weDNUujAGS6-RvdG5wpT4,21007
|
|
5
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/METADATA,sha256=RrYXLshB_1ZL448HUxTxw7RZl1ktNgXbLkZXBtvJ4pY,6342
|
|
6
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/entry_points.txt,sha256=nkewGFi8GZCCtHhFofUmYii3OCeK_5qqgLXE4eUSFZg,98
|
|
8
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/licenses/NOTICE,sha256=VL_gWrK0xFaHGFxxYj6BcZI30EkRxUH4Dv1u2Qsh3ao,92
|
|
10
|
+
awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
awslabs/__init__.py,sha256=4CPNxIm93Eam4qERUKxlLe0tSv0h8nd0ldA40H6lRtI,50
|
|
2
|
-
awslabs/cost_explorer_mcp_server/__init__.py,sha256=hzMRqVmpxqoFbtkSW8Xl6RNRwS_sVKktrHyZM2o9KJ4,168
|
|
3
|
-
awslabs/cost_explorer_mcp_server/helpers.py,sha256=Rt7oVmU3TEVOF8wq4CWb8yJFLjeoSlwKLwSrHb4YA6s,9054
|
|
4
|
-
awslabs/cost_explorer_mcp_server/server.py,sha256=OztURecVW8JOtJZLu6WX97YYOK_GPH-CUgImodI25qE,18388
|
|
5
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/METADATA,sha256=SUGpDEksg1cJj60shYmdiHtkgVZXAF1NJzPxFPknqGY,5848
|
|
6
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/entry_points.txt,sha256=nkewGFi8GZCCtHhFofUmYii3OCeK_5qqgLXE4eUSFZg,98
|
|
8
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/licenses/NOTICE,sha256=VL_gWrK0xFaHGFxxYj6BcZI30EkRxUH4Dv1u2Qsh3ao,92
|
|
10
|
-
awslabs_cost_explorer_mcp_server-0.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|