awslabs.cost-explorer-mcp-server 0.0.2__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 +14 -0
- awslabs/cost_explorer_mcp_server/__init__.py +15 -1
- awslabs/cost_explorer_mcp_server/helpers.py +99 -80
- awslabs/cost_explorer_mcp_server/server.py +120 -101
- {awslabs_cost_explorer_mcp_server-0.0.2.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/METADATA +3 -2
- awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD +10 -0
- awslabs_cost_explorer_mcp_server-0.0.2.dist-info/RECORD +0 -10
- {awslabs_cost_explorer_mcp_server-0.0.2.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/WHEEL +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/entry_points.txt +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2.dist-info → awslabs_cost_explorer_mcp_server-0.0.4.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2.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
|
+
# 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 module.
|
|
2
16
|
|
|
3
17
|
This module provides MCP tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
|
|
4
18
|
"""
|
|
5
19
|
|
|
6
|
-
__version__ =
|
|
20
|
+
__version__ = '0.0.0'
|
|
@@ -1,17 +1,31 @@
|
|
|
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
|
|
|
3
17
|
import boto3
|
|
4
18
|
import logging
|
|
5
19
|
import re
|
|
6
20
|
from datetime import datetime
|
|
7
|
-
from typing import Any, Dict, Tuple
|
|
21
|
+
from typing import Any, Dict, Optional, Tuple
|
|
8
22
|
|
|
9
23
|
|
|
10
24
|
# Set up logging
|
|
11
25
|
logger = logging.getLogger(__name__)
|
|
12
26
|
|
|
13
27
|
# Initialize AWS Cost Explorer client
|
|
14
|
-
ce = boto3.client(
|
|
28
|
+
ce = boto3.client('ce')
|
|
15
29
|
|
|
16
30
|
|
|
17
31
|
def validate_date_format(date_str: str) -> Tuple[bool, str]:
|
|
@@ -24,13 +38,13 @@ def validate_date_format(date_str: str) -> Tuple[bool, str]:
|
|
|
24
38
|
Tuple of (is_valid, error_message)
|
|
25
39
|
"""
|
|
26
40
|
# Check format with regex
|
|
27
|
-
if not re.match(r
|
|
41
|
+
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
28
42
|
return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
|
|
29
43
|
|
|
30
44
|
# Check if it's a valid date
|
|
31
45
|
try:
|
|
32
|
-
datetime.strptime(date_str,
|
|
33
|
-
return True,
|
|
46
|
+
datetime.strptime(date_str, '%Y-%m-%d')
|
|
47
|
+
return True, ''
|
|
34
48
|
except ValueError as e:
|
|
35
49
|
return False, f"Invalid date '{date_str}': {str(e)}"
|
|
36
50
|
|
|
@@ -42,29 +56,29 @@ def get_dimension_values(
|
|
|
42
56
|
# Validate date formats
|
|
43
57
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
44
58
|
if not is_valid_start:
|
|
45
|
-
return {
|
|
59
|
+
return {'error': error_start}
|
|
46
60
|
|
|
47
61
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
48
62
|
if not is_valid_end:
|
|
49
|
-
return {
|
|
63
|
+
return {'error': error_end}
|
|
50
64
|
|
|
51
65
|
# Validate date range
|
|
52
66
|
if billing_period_start > billing_period_end:
|
|
53
67
|
return {
|
|
54
|
-
|
|
68
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
try:
|
|
58
72
|
response = ce.get_dimension_values(
|
|
59
|
-
TimePeriod={
|
|
73
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
60
74
|
Dimension=key.upper(),
|
|
61
75
|
)
|
|
62
|
-
dimension_values = response[
|
|
63
|
-
values = [value[
|
|
64
|
-
return {
|
|
76
|
+
dimension_values = response['DimensionValues']
|
|
77
|
+
values = [value['Value'] for value in dimension_values]
|
|
78
|
+
return {'dimension': key.upper(), 'values': values}
|
|
65
79
|
except Exception as e:
|
|
66
|
-
logger.error(f
|
|
67
|
-
return {
|
|
80
|
+
logger.error(f'Error getting dimension values: {e}')
|
|
81
|
+
return {'error': str(e)}
|
|
68
82
|
|
|
69
83
|
|
|
70
84
|
def get_tag_values(
|
|
@@ -74,28 +88,28 @@ def get_tag_values(
|
|
|
74
88
|
# Validate date formats
|
|
75
89
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
76
90
|
if not is_valid_start:
|
|
77
|
-
return {
|
|
91
|
+
return {'error': error_start}
|
|
78
92
|
|
|
79
93
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
80
94
|
if not is_valid_end:
|
|
81
|
-
return {
|
|
95
|
+
return {'error': error_end}
|
|
82
96
|
|
|
83
97
|
# Validate date range
|
|
84
98
|
if billing_period_start > billing_period_end:
|
|
85
99
|
return {
|
|
86
|
-
|
|
100
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
try:
|
|
90
104
|
response = ce.get_tags(
|
|
91
|
-
TimePeriod={
|
|
105
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
92
106
|
TagKey=tag_key,
|
|
93
107
|
)
|
|
94
|
-
tag_values = response[
|
|
95
|
-
return {
|
|
108
|
+
tag_values = response['Tags']
|
|
109
|
+
return {'tag_key': tag_key, 'values': tag_values}
|
|
96
110
|
except Exception as e:
|
|
97
|
-
logger.error(f
|
|
98
|
-
return {
|
|
111
|
+
logger.error(f'Error getting tag values: {e}')
|
|
112
|
+
return {'error': str(e)}
|
|
99
113
|
|
|
100
114
|
|
|
101
115
|
def validate_expression(
|
|
@@ -114,129 +128,129 @@ def validate_expression(
|
|
|
114
128
|
# Validate date formats
|
|
115
129
|
is_valid_start, error_start = validate_date_format(billing_period_start)
|
|
116
130
|
if not is_valid_start:
|
|
117
|
-
return {
|
|
131
|
+
return {'error': error_start}
|
|
118
132
|
|
|
119
133
|
is_valid_end, error_end = validate_date_format(billing_period_end)
|
|
120
134
|
if not is_valid_end:
|
|
121
|
-
return {
|
|
135
|
+
return {'error': error_end}
|
|
122
136
|
|
|
123
137
|
# Validate date range
|
|
124
138
|
if billing_period_start > billing_period_end:
|
|
125
139
|
return {
|
|
126
|
-
|
|
140
|
+
'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
try:
|
|
130
|
-
if
|
|
131
|
-
dimension = expression[
|
|
144
|
+
if 'Dimensions' in expression:
|
|
145
|
+
dimension = expression['Dimensions']
|
|
132
146
|
if (
|
|
133
|
-
|
|
134
|
-
or
|
|
135
|
-
or
|
|
147
|
+
'Key' not in dimension
|
|
148
|
+
or 'Values' not in dimension
|
|
149
|
+
or 'MatchOptions' not in dimension
|
|
136
150
|
):
|
|
137
151
|
return {
|
|
138
|
-
|
|
152
|
+
'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
|
|
139
153
|
}
|
|
140
154
|
|
|
141
|
-
dimension_key = dimension[
|
|
142
|
-
dimension_values = dimension[
|
|
155
|
+
dimension_key = dimension['Key']
|
|
156
|
+
dimension_values = dimension['Values']
|
|
143
157
|
valid_values_response = get_dimension_values(
|
|
144
158
|
dimension_key, billing_period_start, billing_period_end
|
|
145
159
|
)
|
|
146
|
-
if
|
|
147
|
-
return {
|
|
148
|
-
valid_values = valid_values_response[
|
|
160
|
+
if 'error' in valid_values_response:
|
|
161
|
+
return {'error': valid_values_response['error']}
|
|
162
|
+
valid_values = valid_values_response['values']
|
|
149
163
|
for value in dimension_values:
|
|
150
164
|
if value not in valid_values:
|
|
151
165
|
return {
|
|
152
|
-
|
|
166
|
+
'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
|
|
153
167
|
}
|
|
154
168
|
|
|
155
|
-
if
|
|
156
|
-
tag = expression[
|
|
157
|
-
if
|
|
158
|
-
return {
|
|
169
|
+
if 'Tags' in expression:
|
|
170
|
+
tag = expression['Tags']
|
|
171
|
+
if 'Key' not in tag or 'Values' not in tag or 'MatchOptions' not in tag:
|
|
172
|
+
return {'error': 'Tags filter must include "Key", "Values", and "MatchOptions".'}
|
|
159
173
|
|
|
160
|
-
tag_key = tag[
|
|
161
|
-
tag_values = tag[
|
|
174
|
+
tag_key = tag['Key']
|
|
175
|
+
tag_values = tag['Values']
|
|
162
176
|
valid_tag_values_response = get_tag_values(
|
|
163
177
|
tag_key, billing_period_start, billing_period_end
|
|
164
178
|
)
|
|
165
|
-
if
|
|
166
|
-
return {
|
|
167
|
-
valid_tag_values = valid_tag_values_response[
|
|
179
|
+
if 'error' in valid_tag_values_response:
|
|
180
|
+
return {'error': valid_tag_values_response['error']}
|
|
181
|
+
valid_tag_values = valid_tag_values_response['values']
|
|
168
182
|
for value in tag_values:
|
|
169
183
|
if value not in valid_tag_values:
|
|
170
184
|
return {
|
|
171
|
-
|
|
185
|
+
'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
|
|
172
186
|
}
|
|
173
187
|
|
|
174
|
-
if
|
|
175
|
-
cost_category = expression[
|
|
188
|
+
if 'CostCategories' in expression:
|
|
189
|
+
cost_category = expression['CostCategories']
|
|
176
190
|
if (
|
|
177
|
-
|
|
178
|
-
or
|
|
179
|
-
or
|
|
191
|
+
'Key' not in cost_category
|
|
192
|
+
or 'Values' not in cost_category
|
|
193
|
+
or 'MatchOptions' not in cost_category
|
|
180
194
|
):
|
|
181
195
|
return {
|
|
182
|
-
|
|
196
|
+
'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
|
|
183
197
|
}
|
|
184
198
|
|
|
185
|
-
logical_operators = [
|
|
199
|
+
logical_operators = ['And', 'Or', 'Not']
|
|
186
200
|
logical_count = sum(1 for op in logical_operators if op in expression)
|
|
187
201
|
|
|
188
202
|
if logical_count > 1:
|
|
189
203
|
return {
|
|
190
|
-
|
|
204
|
+
'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'
|
|
191
205
|
}
|
|
192
206
|
|
|
193
207
|
if logical_count == 0 and len(expression) > 1:
|
|
194
208
|
return {
|
|
195
|
-
|
|
209
|
+
'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
|
|
196
210
|
}
|
|
197
211
|
|
|
198
|
-
if
|
|
199
|
-
if not isinstance(expression[
|
|
200
|
-
return {
|
|
201
|
-
for sub_expression in expression[
|
|
212
|
+
if 'And' in expression:
|
|
213
|
+
if not isinstance(expression['And'], list):
|
|
214
|
+
return {'error': 'And expression must be a list of expressions.'}
|
|
215
|
+
for sub_expression in expression['And']:
|
|
202
216
|
result = validate_expression(
|
|
203
217
|
sub_expression, billing_period_start, billing_period_end
|
|
204
218
|
)
|
|
205
|
-
if
|
|
219
|
+
if 'error' in result:
|
|
206
220
|
return result
|
|
207
221
|
|
|
208
|
-
if
|
|
209
|
-
if not isinstance(expression[
|
|
210
|
-
return {
|
|
211
|
-
for sub_expression in expression[
|
|
222
|
+
if 'Or' in expression:
|
|
223
|
+
if not isinstance(expression['Or'], list):
|
|
224
|
+
return {'error': 'Or expression must be a list of expressions.'}
|
|
225
|
+
for sub_expression in expression['Or']:
|
|
212
226
|
result = validate_expression(
|
|
213
227
|
sub_expression, billing_period_start, billing_period_end
|
|
214
228
|
)
|
|
215
|
-
if
|
|
229
|
+
if 'error' in result:
|
|
216
230
|
return result
|
|
217
231
|
|
|
218
|
-
if
|
|
219
|
-
if not isinstance(expression[
|
|
220
|
-
return {
|
|
232
|
+
if 'Not' in expression:
|
|
233
|
+
if not isinstance(expression['Not'], dict):
|
|
234
|
+
return {'error': 'Not expression must be a single expression.'}
|
|
221
235
|
result = validate_expression(
|
|
222
|
-
expression[
|
|
236
|
+
expression['Not'], billing_period_start, billing_period_end
|
|
223
237
|
)
|
|
224
|
-
if
|
|
238
|
+
if 'error' in result:
|
|
225
239
|
return result
|
|
226
240
|
|
|
227
241
|
if not any(
|
|
228
|
-
k in expression for k in [
|
|
242
|
+
k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
|
|
229
243
|
):
|
|
230
244
|
return {
|
|
231
|
-
|
|
245
|
+
'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
return {}
|
|
235
249
|
except Exception as e:
|
|
236
|
-
return {
|
|
250
|
+
return {'error': f'Error validating expression: {str(e)}'}
|
|
237
251
|
|
|
238
252
|
|
|
239
|
-
def validate_group_by(group_by: Dict[str, Any]) -> Dict[str, Any]:
|
|
253
|
+
def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
240
254
|
"""Validate the group_by parameter.
|
|
241
255
|
|
|
242
256
|
Args:
|
|
@@ -246,14 +260,19 @@ def validate_group_by(group_by: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
246
260
|
Empty dictionary if valid, or an error dictionary
|
|
247
261
|
"""
|
|
248
262
|
try:
|
|
249
|
-
if
|
|
250
|
-
|
|
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
|
+
):
|
|
269
|
+
return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
|
|
251
270
|
|
|
252
|
-
if group_by[
|
|
271
|
+
if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
|
|
253
272
|
return {
|
|
254
|
-
|
|
273
|
+
'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
return {}
|
|
258
277
|
except Exception as e:
|
|
259
|
-
return {
|
|
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.
|
|
@@ -24,7 +38,7 @@ logging.basicConfig(level=logging.INFO)
|
|
|
24
38
|
logger = logging.getLogger(__name__)
|
|
25
39
|
|
|
26
40
|
# Initialize AWS Cost Explorer client
|
|
27
|
-
ce = boto3.client(
|
|
41
|
+
ce = boto3.client('ce')
|
|
28
42
|
|
|
29
43
|
|
|
30
44
|
class DateRange(BaseModel):
|
|
@@ -32,13 +46,13 @@ class DateRange(BaseModel):
|
|
|
32
46
|
|
|
33
47
|
start_date: str = Field(
|
|
34
48
|
...,
|
|
35
|
-
description=
|
|
49
|
+
description='The start date of the billing period in YYYY-MM-DD format. Defaults to last month, if not provided.',
|
|
36
50
|
)
|
|
37
51
|
end_date: str = Field(
|
|
38
|
-
..., description=
|
|
52
|
+
..., description='The end date of the billing period in YYYY-MM-DD format.'
|
|
39
53
|
)
|
|
40
54
|
|
|
41
|
-
@field_validator(
|
|
55
|
+
@field_validator('start_date')
|
|
42
56
|
@classmethod
|
|
43
57
|
def validate_start_date(cls, v):
|
|
44
58
|
"""Validate that start_date is in YYYY-MM-DD format and is a valid date."""
|
|
@@ -47,7 +61,7 @@ class DateRange(BaseModel):
|
|
|
47
61
|
raise ValueError(error)
|
|
48
62
|
return v
|
|
49
63
|
|
|
50
|
-
@field_validator(
|
|
64
|
+
@field_validator('end_date')
|
|
51
65
|
@classmethod
|
|
52
66
|
def validate_end_date(cls, v, info):
|
|
53
67
|
"""Validate that end_date is in YYYY-MM-DD format and is a valid date, and not before start_date."""
|
|
@@ -56,7 +70,7 @@ class DateRange(BaseModel):
|
|
|
56
70
|
raise ValueError(error)
|
|
57
71
|
|
|
58
72
|
# Access the start_date from the data dictionary
|
|
59
|
-
start_date = info.data.get(
|
|
73
|
+
start_date = info.data.get('start_date')
|
|
60
74
|
if start_date and v < start_date:
|
|
61
75
|
raise ValueError(f"End date '{v}' cannot be before start date '{start_date}'")
|
|
62
76
|
|
|
@@ -68,11 +82,11 @@ class GroupBy(BaseModel):
|
|
|
68
82
|
|
|
69
83
|
type: str = Field(
|
|
70
84
|
...,
|
|
71
|
-
description=
|
|
85
|
+
description='Type of grouping. Valid values are DIMENSION, TAG, and COST_CATEGORY.',
|
|
72
86
|
)
|
|
73
87
|
key: str = Field(
|
|
74
88
|
...,
|
|
75
|
-
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.',
|
|
76
90
|
)
|
|
77
91
|
|
|
78
92
|
|
|
@@ -89,8 +103,8 @@ class CostMetric(BaseModel):
|
|
|
89
103
|
"""Cost metric model."""
|
|
90
104
|
|
|
91
105
|
metric: str = Field(
|
|
92
|
-
|
|
93
|
-
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.',
|
|
94
108
|
)
|
|
95
109
|
|
|
96
110
|
|
|
@@ -99,15 +113,15 @@ class DimensionKey(BaseModel):
|
|
|
99
113
|
|
|
100
114
|
dimension_key: str = Field(
|
|
101
115
|
...,
|
|
102
|
-
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.',
|
|
103
117
|
)
|
|
104
118
|
|
|
105
119
|
|
|
106
120
|
# Create FastMCP server
|
|
107
|
-
app = FastMCP(title=
|
|
121
|
+
app = FastMCP(title='Cost Explorer MCP Server')
|
|
108
122
|
|
|
109
123
|
|
|
110
|
-
@app.tool(
|
|
124
|
+
@app.tool('get_today_date')
|
|
111
125
|
async def get_today_date(ctx: Context) -> Dict[str, str]:
|
|
112
126
|
"""Retrieve current date information.
|
|
113
127
|
|
|
@@ -121,12 +135,12 @@ async def get_today_date(ctx: Context) -> Dict[str, str]:
|
|
|
121
135
|
Dictionary containing today's date and current month
|
|
122
136
|
"""
|
|
123
137
|
return {
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
'today_date': datetime.now().strftime('%Y-%m-%d'),
|
|
139
|
+
'current_month': datetime.now().strftime('%Y-%m'),
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
|
|
129
|
-
@app.tool(
|
|
143
|
+
@app.tool('get_dimension_values')
|
|
130
144
|
async def get_dimension_values_tool(
|
|
131
145
|
ctx: Context, date_range: DateRange, dimension: DimensionKey
|
|
132
146
|
) -> Dict[str, Any]:
|
|
@@ -150,15 +164,15 @@ async def get_dimension_values_tool(
|
|
|
150
164
|
)
|
|
151
165
|
return response
|
|
152
166
|
except Exception as e:
|
|
153
|
-
logger.error(f
|
|
154
|
-
return {
|
|
167
|
+
logger.error(f'Error getting dimension values: {e}')
|
|
168
|
+
return {'error': f'Error getting dimension values: {str(e)}'}
|
|
155
169
|
|
|
156
170
|
|
|
157
|
-
@app.tool(
|
|
171
|
+
@app.tool('get_tag_values')
|
|
158
172
|
async def get_tag_values_tool(
|
|
159
173
|
ctx: Context,
|
|
160
174
|
date_range: DateRange,
|
|
161
|
-
tag_key: str = Field(..., description=
|
|
175
|
+
tag_key: str = Field(..., description='The tag key to retrieve values for'),
|
|
162
176
|
) -> Dict[str, Any]:
|
|
163
177
|
"""Retrieve available tag values for AWS Cost Explorer.
|
|
164
178
|
|
|
@@ -177,17 +191,17 @@ async def get_tag_values_tool(
|
|
|
177
191
|
response = get_tag_values(tag_key, date_range.start_date, date_range.end_date)
|
|
178
192
|
return response
|
|
179
193
|
except Exception as e:
|
|
180
|
-
logger.error(f
|
|
181
|
-
return {
|
|
194
|
+
logger.error(f'Error getting tag values: {e}')
|
|
195
|
+
return {'error': f'Error getting tag values: {str(e)}'}
|
|
182
196
|
|
|
183
197
|
|
|
184
|
-
@app.tool(
|
|
198
|
+
@app.tool('get_cost_and_usage')
|
|
185
199
|
async def get_cost_and_usage(
|
|
186
200
|
ctx: Context,
|
|
187
201
|
date_range: DateRange,
|
|
188
202
|
granularity: str = Field(
|
|
189
|
-
|
|
190
|
-
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.',
|
|
191
205
|
),
|
|
192
206
|
group_by: Optional[Union[Dict[str, str], str]] = Field(
|
|
193
207
|
None,
|
|
@@ -198,8 +212,8 @@ async def get_cost_and_usage(
|
|
|
198
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']}}]}.",
|
|
199
213
|
),
|
|
200
214
|
metric: str = Field(
|
|
201
|
-
|
|
202
|
-
description=
|
|
215
|
+
'UnblendedCost',
|
|
216
|
+
description='The metric to return in the query. Valid values are AmortizedCost, BlendedCost, NetAmortizedCost, NetUnblendedCost, NormalizedUsageAmount, UnblendedCost, and UsageQuantity.',
|
|
203
217
|
),
|
|
204
218
|
) -> Dict[str, Any]:
|
|
205
219
|
"""Retrieve AWS cost and usage data.
|
|
@@ -255,10 +269,15 @@ async def get_cost_and_usage(
|
|
|
255
269
|
"""
|
|
256
270
|
try:
|
|
257
271
|
# Process inputs
|
|
258
|
-
granularity
|
|
259
|
-
|
|
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']:
|
|
260
279
|
return {
|
|
261
|
-
|
|
280
|
+
'error': f'Invalid granularity: {granularity}. Valid values are DAILY, MONTHLY, and HOURLY.'
|
|
262
281
|
}
|
|
263
282
|
|
|
264
283
|
billing_period_start = date_range.start_date
|
|
@@ -266,17 +285,17 @@ async def get_cost_and_usage(
|
|
|
266
285
|
|
|
267
286
|
# Define valid metrics and their expected data structure
|
|
268
287
|
valid_metrics = {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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},
|
|
275
294
|
}
|
|
276
295
|
|
|
277
296
|
if metric not in valid_metrics:
|
|
278
297
|
return {
|
|
279
|
-
|
|
298
|
+
'error': f'Invalid metric: {metric}. Valid values are {", ".join(valid_metrics.keys())}.'
|
|
280
299
|
}
|
|
281
300
|
|
|
282
301
|
metric_config = valid_metrics[metric]
|
|
@@ -284,8 +303,8 @@ async def get_cost_and_usage(
|
|
|
284
303
|
# Adjust end date for Cost Explorer API (exclusive)
|
|
285
304
|
# Add one day to make the end date inclusive for the user
|
|
286
305
|
billing_period_end_adj = (
|
|
287
|
-
datetime.strptime(billing_period_end,
|
|
288
|
-
).strftime(
|
|
306
|
+
datetime.strptime(billing_period_end, '%Y-%m-%d') + timedelta(days=1)
|
|
307
|
+
).strftime('%Y-%m-%d')
|
|
289
308
|
|
|
290
309
|
# Process filter
|
|
291
310
|
filter_criteria = filter_expression
|
|
@@ -296,159 +315,159 @@ async def get_cost_and_usage(
|
|
|
296
315
|
validation_result = validate_expression(
|
|
297
316
|
filter_criteria, billing_period_start, billing_period_end_adj
|
|
298
317
|
)
|
|
299
|
-
if
|
|
318
|
+
if 'error' in validation_result:
|
|
300
319
|
return validation_result
|
|
301
320
|
|
|
302
321
|
# Process group_by
|
|
303
322
|
if not group_by:
|
|
304
|
-
group_by = {
|
|
323
|
+
group_by = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
|
|
305
324
|
elif isinstance(group_by, str):
|
|
306
|
-
group_by = {
|
|
325
|
+
group_by = {'Type': 'DIMENSION', 'Key': group_by}
|
|
307
326
|
|
|
308
327
|
# Validate group_by using the existing validate_group_by function
|
|
309
328
|
validation_result = validate_group_by(group_by)
|
|
310
|
-
if
|
|
329
|
+
if 'error' in validation_result:
|
|
311
330
|
return validation_result
|
|
312
331
|
|
|
313
332
|
# Prepare API call parameters
|
|
314
333
|
common_params = {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
334
|
+
'TimePeriod': {
|
|
335
|
+
'Start': billing_period_start,
|
|
336
|
+
'End': billing_period_end_adj,
|
|
318
337
|
},
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
338
|
+
'Granularity': granularity,
|
|
339
|
+
'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
|
|
340
|
+
'Metrics': [metric],
|
|
322
341
|
}
|
|
323
342
|
|
|
324
343
|
if filter_criteria:
|
|
325
|
-
common_params[
|
|
344
|
+
common_params['Filter'] = filter_criteria
|
|
326
345
|
|
|
327
346
|
# Get cost data
|
|
328
347
|
grouped_costs = {}
|
|
329
348
|
next_token = None
|
|
330
349
|
while True:
|
|
331
350
|
if next_token:
|
|
332
|
-
common_params[
|
|
351
|
+
common_params['NextPageToken'] = next_token
|
|
333
352
|
|
|
334
353
|
try:
|
|
335
354
|
response = ce.get_cost_and_usage(**common_params)
|
|
336
355
|
except Exception as e:
|
|
337
|
-
logger.error(f
|
|
338
|
-
return {
|
|
339
|
-
|
|
340
|
-
for result_by_time in response[
|
|
341
|
-
date = result_by_time[
|
|
342
|
-
for group in result_by_time.get(
|
|
343
|
-
if not group.get(
|
|
344
|
-
logger.warning(f
|
|
356
|
+
logger.error(f'Error calling Cost Explorer API: {e}')
|
|
357
|
+
return {'error': f'AWS Cost Explorer API error: {str(e)}'}
|
|
358
|
+
|
|
359
|
+
for result_by_time in response['ResultsByTime']:
|
|
360
|
+
date = result_by_time['TimePeriod']['Start']
|
|
361
|
+
for group in result_by_time.get('Groups', []):
|
|
362
|
+
if not group.get('Keys') or len(group['Keys']) == 0:
|
|
363
|
+
logger.warning(f'Skipping group with no keys: {group}')
|
|
345
364
|
continue
|
|
346
365
|
|
|
347
|
-
group_key = group[
|
|
366
|
+
group_key = group['Keys'][0]
|
|
348
367
|
|
|
349
368
|
# Validate that the metric exists in the response
|
|
350
|
-
if metric not in group.get(
|
|
369
|
+
if metric not in group.get('Metrics', {}):
|
|
351
370
|
logger.error(
|
|
352
371
|
f"Metric '{metric}' not found in response for group {group_key}"
|
|
353
372
|
)
|
|
354
373
|
return {
|
|
355
|
-
|
|
374
|
+
'error': f"Metric '{metric}' not found in response for group {group_key}"
|
|
356
375
|
}
|
|
357
376
|
|
|
358
|
-
metric_data = group[
|
|
377
|
+
metric_data = group['Metrics'][metric]
|
|
359
378
|
|
|
360
379
|
# Validate metric data structure
|
|
361
|
-
if
|
|
380
|
+
if 'Amount' not in metric_data:
|
|
362
381
|
logger.error(
|
|
363
|
-
f
|
|
382
|
+
f'Amount not found in metric data for {group_key}: {metric_data}'
|
|
364
383
|
)
|
|
365
384
|
return {
|
|
366
|
-
|
|
385
|
+
'error': "Invalid response format: 'Amount' not found in metric data"
|
|
367
386
|
}
|
|
368
387
|
|
|
369
388
|
try:
|
|
370
|
-
metric_data = group[
|
|
389
|
+
metric_data = group['Metrics'][metric]
|
|
371
390
|
|
|
372
391
|
# Validate metric data structure
|
|
373
|
-
if
|
|
392
|
+
if 'Amount' not in metric_data:
|
|
374
393
|
logger.error(
|
|
375
|
-
f
|
|
394
|
+
f'Amount not found in metric data for {group_key}: {metric_data}'
|
|
376
395
|
)
|
|
377
396
|
return {
|
|
378
|
-
|
|
397
|
+
'error': "Invalid response format: 'Amount' not found in metric data"
|
|
379
398
|
}
|
|
380
399
|
|
|
381
400
|
# Process based on metric type
|
|
382
|
-
if metric_config[
|
|
401
|
+
if metric_config['is_cost']:
|
|
383
402
|
# Handle cost metrics
|
|
384
|
-
cost = float(metric_data[
|
|
403
|
+
cost = float(metric_data['Amount'])
|
|
385
404
|
grouped_costs.setdefault(date, {}).update({group_key: cost})
|
|
386
405
|
else:
|
|
387
406
|
# Handle usage metrics (UsageQuantity, NormalizedUsageAmount)
|
|
388
|
-
if
|
|
407
|
+
if 'Unit' not in metric_data and metric_config['has_unit']:
|
|
389
408
|
logger.warning(
|
|
390
409
|
f"Unit not found in {metric} data for {group_key}, using 'Unknown' as unit"
|
|
391
410
|
)
|
|
392
|
-
unit =
|
|
411
|
+
unit = 'Unknown'
|
|
393
412
|
else:
|
|
394
|
-
unit = metric_data.get(
|
|
395
|
-
amount = float(metric_data[
|
|
413
|
+
unit = metric_data.get('Unit', 'Count')
|
|
414
|
+
amount = float(metric_data['Amount'])
|
|
396
415
|
grouped_costs.setdefault(date, {}).update({group_key: (amount, unit)})
|
|
397
416
|
except (ValueError, TypeError) as e:
|
|
398
|
-
logger.error(f
|
|
399
|
-
return {
|
|
417
|
+
logger.error(f'Error processing metric data: {e}, data: {metric_data}')
|
|
418
|
+
return {'error': f'Error processing metric data: {str(e)}'}
|
|
400
419
|
|
|
401
|
-
next_token = response.get(
|
|
420
|
+
next_token = response.get('NextPageToken')
|
|
402
421
|
if not next_token:
|
|
403
422
|
break
|
|
404
423
|
|
|
405
424
|
# Process results
|
|
406
425
|
if not grouped_costs:
|
|
407
|
-
logger.info(
|
|
426
|
+
logger.info('No cost data found for the specified parameters')
|
|
408
427
|
return {
|
|
409
|
-
|
|
410
|
-
|
|
428
|
+
'message': 'No cost data found for the specified parameters',
|
|
429
|
+
'GroupedCosts': {},
|
|
411
430
|
}
|
|
412
431
|
|
|
413
432
|
try:
|
|
414
|
-
if metric_config[
|
|
433
|
+
if metric_config['is_cost']:
|
|
415
434
|
# Process cost metrics
|
|
416
435
|
df = pd.DataFrame.from_dict(grouped_costs).round(2)
|
|
417
|
-
df[
|
|
418
|
-
df.loc[
|
|
419
|
-
df = df.sort_values(by=
|
|
436
|
+
df['Service total'] = df.sum(axis=1).round(2)
|
|
437
|
+
df.loc['Total Costs'] = df.sum().round(2)
|
|
438
|
+
df = df.sort_values(by='Service total', ascending=False)
|
|
420
439
|
else:
|
|
421
440
|
# Process usage metrics (UsageQuantity, NormalizedUsageAmount)
|
|
422
441
|
usage_df = pd.DataFrame(
|
|
423
442
|
{
|
|
424
|
-
(k,
|
|
443
|
+
(k, 'Amount'): {k1: v1[0] for k1, v1 in v.items()}
|
|
425
444
|
for k, v in grouped_costs.items()
|
|
426
445
|
}
|
|
427
446
|
)
|
|
428
447
|
units_df = pd.DataFrame(
|
|
429
448
|
{
|
|
430
|
-
(k,
|
|
449
|
+
(k, 'Unit'): {k1: v1[1] for k1, v1 in v.items()}
|
|
431
450
|
for k, v in grouped_costs.items()
|
|
432
451
|
}
|
|
433
452
|
)
|
|
434
453
|
df = pd.concat([usage_df, units_df], axis=1)
|
|
435
454
|
|
|
436
|
-
result = {
|
|
455
|
+
result = {'GroupedCosts': df.to_dict()}
|
|
437
456
|
except Exception as e:
|
|
438
|
-
logger.error(f
|
|
457
|
+
logger.error(f'Error processing cost data into DataFrame: {e}')
|
|
439
458
|
return {
|
|
440
|
-
|
|
441
|
-
|
|
459
|
+
'error': f'Error processing cost data: {str(e)}',
|
|
460
|
+
'raw_data': grouped_costs,
|
|
442
461
|
}
|
|
443
462
|
|
|
444
|
-
result = {
|
|
463
|
+
result = {'GroupedCosts': df.to_dict()}
|
|
445
464
|
|
|
446
465
|
# Convert all keys to strings for JSON serialization
|
|
447
|
-
def stringify_keys(d):
|
|
466
|
+
def stringify_keys(d: Any) -> Any:
|
|
448
467
|
if isinstance(d, dict):
|
|
449
468
|
return {str(k): stringify_keys(v) for k, v in d.items()}
|
|
450
469
|
elif isinstance(d, list):
|
|
451
|
-
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
|
|
452
471
|
else:
|
|
453
472
|
return d
|
|
454
473
|
|
|
@@ -456,15 +475,15 @@ async def get_cost_and_usage(
|
|
|
456
475
|
result = stringify_keys(result)
|
|
457
476
|
return result
|
|
458
477
|
except Exception as e:
|
|
459
|
-
logger.error(f
|
|
460
|
-
return {
|
|
478
|
+
logger.error(f'Error serializing result: {e}')
|
|
479
|
+
return {'error': f'Error serializing result: {str(e)}'}
|
|
461
480
|
|
|
462
481
|
except Exception as e:
|
|
463
|
-
logger.error(f
|
|
482
|
+
logger.error(f'Error generating cost report: {e}')
|
|
464
483
|
import traceback
|
|
465
484
|
|
|
466
|
-
logger.error(f
|
|
467
|
-
return {
|
|
485
|
+
logger.error(f'Traceback: {traceback.format_exc()}')
|
|
486
|
+
return {'error': f'Error generating cost report: {str(e)}'}
|
|
468
487
|
|
|
469
488
|
|
|
470
489
|
def main():
|
|
@@ -472,5 +491,5 @@ def main():
|
|
|
472
491
|
app.run()
|
|
473
492
|
|
|
474
493
|
|
|
475
|
-
if __name__ ==
|
|
494
|
+
if __name__ == '__main__':
|
|
476
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,7 @@ 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
|
+
|
|
130
131
|
## Cost Considerations
|
|
131
132
|
|
|
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.
|
|
@@ -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=vi9O_PzTkEpojELcg0v9esQUWLbseZTynQB4YpNYzA8,51
|
|
2
|
-
awslabs/cost_explorer_mcp_server/__init__.py,sha256=DkDBVzhBu0iM3Y6ZrDi6zTydT3mtgKtwjavEhmLBnl0,169
|
|
3
|
-
awslabs/cost_explorer_mcp_server/helpers.py,sha256=Snc2rhsNFbl7WmLBbrIYR21jmKU7QAjshLiv26RyilU,9470
|
|
4
|
-
awslabs/cost_explorer_mcp_server/server.py,sha256=MTHU04Wm0izmrk52fWAQw-y9TpG2O-ACKzfcA_-2Rp0,20150
|
|
5
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/METADATA,sha256=kUuzyv89t_LOq1UNDfcinZc97ZcAfR9mnEc162kK7iM,6340
|
|
6
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/entry_points.txt,sha256=nkewGFi8GZCCtHhFofUmYii3OCeK_5qqgLXE4eUSFZg,98
|
|
8
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/licenses/NOTICE,sha256=VL_gWrK0xFaHGFxxYj6BcZI30EkRxUH4Dv1u2Qsh3ao,92
|
|
10
|
-
awslabs_cost_explorer_mcp_server-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|