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 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__ = "0.0.0"
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("ce")
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"^\d{4}-\d{2}-\d{2}$", date_str):
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, "%Y-%m-%d")
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 {"error": error_start}
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 {"error": error_end}
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
- "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
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={"Start": billing_period_start, "End": billing_period_end},
73
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
60
74
  Dimension=key.upper(),
61
75
  )
62
- dimension_values = response["DimensionValues"]
63
- values = [value["Value"] for value in dimension_values]
64
- return {"dimension": key.upper(), "values": values}
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"Error getting dimension values: {e}")
67
- return {"error": str(e)}
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 {"error": error_start}
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 {"error": error_end}
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
- "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
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={"Start": billing_period_start, "End": billing_period_end},
105
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
92
106
  TagKey=tag_key,
93
107
  )
94
- tag_values = response["Tags"]
95
- return {"tag_key": tag_key, "values": tag_values}
108
+ tag_values = response['Tags']
109
+ return {'tag_key': tag_key, 'values': tag_values}
96
110
  except Exception as e:
97
- logger.error(f"Error getting tag values: {e}")
98
- return {"error": str(e)}
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 {"error": error_start}
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 {"error": error_end}
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
- "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
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 "Dimensions" in expression:
131
- dimension = expression["Dimensions"]
144
+ if 'Dimensions' in expression:
145
+ dimension = expression['Dimensions']
132
146
  if (
133
- "Key" not in dimension
134
- or "Values" not in dimension
135
- or "MatchOptions" not in dimension
147
+ 'Key' not in dimension
148
+ or 'Values' not in dimension
149
+ or 'MatchOptions' not in dimension
136
150
  ):
137
151
  return {
138
- "error": 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
152
+ 'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
139
153
  }
140
154
 
141
- dimension_key = dimension["Key"]
142
- dimension_values = dimension["Values"]
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 "error" in valid_values_response:
147
- return {"error": valid_values_response["error"]}
148
- valid_values = valid_values_response["values"]
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
- "error": f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
166
+ 'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
153
167
  }
154
168
 
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".'}
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["Key"]
161
- tag_values = tag["Values"]
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 "error" in valid_tag_values_response:
166
- return {"error": valid_tag_values_response["error"]}
167
- valid_tag_values = valid_tag_values_response["values"]
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
- "error": f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
185
+ 'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
172
186
  }
173
187
 
174
- if "CostCategories" in expression:
175
- cost_category = expression["CostCategories"]
188
+ if 'CostCategories' in expression:
189
+ cost_category = expression['CostCategories']
176
190
  if (
177
- "Key" not in cost_category
178
- or "Values" not in cost_category
179
- or "MatchOptions" not in cost_category
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
- "error": 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
196
+ 'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
183
197
  }
184
198
 
185
- logical_operators = ["And", "Or", "Not"]
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
- "error": "Only one logical operator (And, Or, Not) is allowed per expression in filter parameter."
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
- "error": "Filter parameter with multiple expressions require a logical operator (And, Or, Not)."
209
+ 'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
196
210
  }
197
211
 
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"]:
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 "error" in result:
219
+ if 'error' in result:
206
220
  return result
207
221
 
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"]:
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 "error" in result:
229
+ if 'error' in result:
216
230
  return result
217
231
 
218
- if "Not" in expression:
219
- if not isinstance(expression["Not"], dict):
220
- return {"error": "Not expression must be a single expression."}
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["Not"], billing_period_start, billing_period_end
236
+ expression['Not'], billing_period_start, billing_period_end
223
237
  )
224
- if "error" in result:
238
+ if 'error' in result:
225
239
  return result
226
240
 
227
241
  if not any(
228
- k in expression for k in ["Dimensions", "Tags", "CostCategories", "And", "Or", "Not"]
242
+ k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
229
243
  ):
230
244
  return {
231
- "error": 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
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 {"error": f"Error validating expression: {str(e)}"}
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 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.'}
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["Type"].upper() not in ["DIMENSION", "TAG", "COST_CATEGORY"]:
271
+ if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
253
272
  return {
254
- "error": "Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY."
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 {"error": f"Error validating group_by: {str(e)}"}
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("ce")
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="The start date of the billing period in YYYY-MM-DD format. Defaults to last month, if not provided.",
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="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
- @field_validator("start_date")
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("end_date")
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("start_date")
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="Type of grouping. Valid values are DIMENSION, TAG, and COST_CATEGORY.",
85
+ description='Type of grouping. Valid values are DIMENSION, TAG, and COST_CATEGORY.',
72
86
  )
73
87
  key: str = Field(
74
88
  ...,
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.",
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
- "UnblendedCost",
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.",
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="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.",
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="Cost Explorer MCP Server")
121
+ app = FastMCP(title='Cost Explorer MCP Server')
108
122
 
109
123
 
110
- @app.tool("get_today_date")
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
- "today_date": datetime.now().strftime("%Y-%m-%d"),
125
- "current_month": datetime.now().strftime("%Y-%m"),
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("get_dimension_values")
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"Error getting dimension values: {e}")
154
- return {"error": f"Error getting dimension values: {str(e)}"}
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("get_tag_values")
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="The tag key to retrieve values for"),
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"Error getting tag values: {e}")
181
- return {"error": f"Error getting tag values: {str(e)}"}
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("get_cost_and_usage")
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
- "MONTHLY",
190
- description="The granularity at which cost data is aggregated. Valid values are DAILY, MONTHLY, and HOURLY. If not provided, defaults to MONTHLY.",
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
- "UnblendedCost",
202
- description="The metric to return in the query. Valid values are AmortizedCost, BlendedCost, NetAmortizedCost, NetUnblendedCost, NormalizedUsageAmount, UnblendedCost, and UsageQuantity.",
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 = granularity.upper()
259
- if granularity not in ["DAILY", "MONTHLY", "HOURLY"]:
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
- "error": f"Invalid granularity: {granularity}. Valid values are DAILY, MONTHLY, and HOURLY."
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
- "AmortizedCost": {"has_unit": True, "is_cost": True},
270
- "BlendedCost": {"has_unit": True, "is_cost": True},
271
- "NetAmortizedCost": {"has_unit": True, "is_cost": True},
272
- "NetUnblendedCost": {"has_unit": True, "is_cost": True},
273
- "UnblendedCost": {"has_unit": True, "is_cost": True},
274
- "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},
275
294
  }
276
295
 
277
296
  if metric not in valid_metrics:
278
297
  return {
279
- "error": f"Invalid metric: {metric}. Valid values are {', '.join(valid_metrics.keys())}."
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, "%Y-%m-%d") + timedelta(days=1)
288
- ).strftime("%Y-%m-%d")
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 "error" in validation_result:
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 = {"Type": "DIMENSION", "Key": "SERVICE"}
323
+ group_by = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
305
324
  elif isinstance(group_by, str):
306
- group_by = {"Type": "DIMENSION", "Key": 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 "error" in validation_result:
329
+ if 'error' in validation_result:
311
330
  return validation_result
312
331
 
313
332
  # Prepare API call parameters
314
333
  common_params = {
315
- "TimePeriod": {
316
- "Start": billing_period_start,
317
- "End": billing_period_end_adj,
334
+ 'TimePeriod': {
335
+ 'Start': billing_period_start,
336
+ 'End': billing_period_end_adj,
318
337
  },
319
- "Granularity": granularity,
320
- "GroupBy": [{"Type": group_by["Type"].upper(), "Key": group_by["Key"]}],
321
- "Metrics": [metric],
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["Filter"] = filter_criteria
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["NextPageToken"] = next_token
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"Error calling Cost Explorer API: {e}")
338
- return {"error": f"AWS Cost Explorer API error: {str(e)}"}
339
-
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:
344
- logger.warning(f"Skipping group with no keys: {group}")
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["Keys"][0]
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("Metrics", {}):
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
- "error": f"Metric '{metric}' not found in response for group {group_key}"
374
+ 'error': f"Metric '{metric}' not found in response for group {group_key}"
356
375
  }
357
376
 
358
- metric_data = group["Metrics"][metric]
377
+ metric_data = group['Metrics'][metric]
359
378
 
360
379
  # Validate metric data structure
361
- if "Amount" not in metric_data:
380
+ if 'Amount' not in metric_data:
362
381
  logger.error(
363
- f"Amount not found in metric data for {group_key}: {metric_data}"
382
+ f'Amount not found in metric data for {group_key}: {metric_data}'
364
383
  )
365
384
  return {
366
- "error": "Invalid response format: 'Amount' not found in metric data"
385
+ 'error': "Invalid response format: 'Amount' not found in metric data"
367
386
  }
368
387
 
369
388
  try:
370
- metric_data = group["Metrics"][metric]
389
+ metric_data = group['Metrics'][metric]
371
390
 
372
391
  # Validate metric data structure
373
- if "Amount" not in metric_data:
392
+ if 'Amount' not in metric_data:
374
393
  logger.error(
375
- f"Amount not found in metric data for {group_key}: {metric_data}"
394
+ f'Amount not found in metric data for {group_key}: {metric_data}'
376
395
  )
377
396
  return {
378
- "error": "Invalid response format: 'Amount' not found in metric data"
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["is_cost"]:
401
+ if metric_config['is_cost']:
383
402
  # Handle cost metrics
384
- cost = float(metric_data["Amount"])
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 "Unit" not in metric_data and metric_config["has_unit"]:
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 = "Unknown"
411
+ unit = 'Unknown'
393
412
  else:
394
- unit = metric_data.get("Unit", "Count")
395
- amount = float(metric_data["Amount"])
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"Error processing metric data: {e}, data: {metric_data}")
399
- return {"error": f"Error processing metric data: {str(e)}"}
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("NextPageToken")
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("No cost data found for the specified parameters")
426
+ logger.info('No cost data found for the specified parameters')
408
427
  return {
409
- "message": "No cost data found for the specified parameters",
410
- "GroupedCosts": {},
428
+ 'message': 'No cost data found for the specified parameters',
429
+ 'GroupedCosts': {},
411
430
  }
412
431
 
413
432
  try:
414
- if metric_config["is_cost"]:
433
+ if metric_config['is_cost']:
415
434
  # Process cost metrics
416
435
  df = pd.DataFrame.from_dict(grouped_costs).round(2)
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)
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, "Amount"): {k1: v1[0] for k1, v1 in v.items()}
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, "Unit"): {k1: v1[1] for k1, v1 in v.items()}
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 = {"GroupedCosts": df.to_dict()}
455
+ result = {'GroupedCosts': df.to_dict()}
437
456
  except Exception as e:
438
- logger.error(f"Error processing cost data into DataFrame: {e}")
457
+ logger.error(f'Error processing cost data into DataFrame: {e}')
439
458
  return {
440
- "error": f"Error processing cost data: {str(e)}",
441
- "raw_data": grouped_costs,
459
+ 'error': f'Error processing cost data: {str(e)}',
460
+ 'raw_data': grouped_costs,
442
461
  }
443
462
 
444
- result = {"GroupedCosts": df.to_dict()}
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"Error serializing result: {e}")
460
- return {"error": f"Error serializing result: {str(e)}"}
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"Error generating cost report: {e}")
482
+ logger.error(f'Error generating cost report: {e}')
464
483
  import traceback
465
484
 
466
- logger.error(f"Traceback: {traceback.format_exc()}")
467
- return {"error": f"Error generating cost report: {str(e)}"}
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__ == "__main__":
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.2
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: fastmcp>=0.1.0
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,,