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