awslabs.cost-explorer-mcp-server 0.0.4__tar.gz → 0.0.5__tar.gz

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.
Files changed (21) hide show
  1. awslabs_cost_explorer_mcp_server-0.0.5/CHANGELOG.md +27 -0
  2. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile +2 -2
  3. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/PKG-INFO +4 -1
  4. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/README.md +2 -0
  5. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/awslabs/cost_explorer_mcp_server/helpers.py +160 -49
  6. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/awslabs/cost_explorer_mcp_server/server.py +141 -119
  7. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/pyproject.toml +2 -1
  8. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/conftest.py +48 -8
  9. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/test_helpers.py +264 -11
  10. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/test_server.py +545 -52
  11. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/uv.lock +25 -1
  12. awslabs_cost_explorer_mcp_server-0.0.4/.pre-commit-config.yaml +0 -14
  13. awslabs_cost_explorer_mcp_server-0.0.4/CHANGELOG.md +0 -11
  14. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/.gitignore +0 -0
  15. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/.python-version +0 -0
  16. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/LICENSE +0 -0
  17. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/NOTICE +0 -0
  18. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/awslabs/__init__.py +0 -0
  19. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/awslabs/cost_explorer_mcp_server/__init__.py +0 -0
  20. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/docker-healthcheck.sh +0 -0
  21. {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/__init__.py +0 -0
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.5.0] - 2025-06-16
9
+
10
+ ### Added
11
+ - Enhanced documentation for UsageQuantity metric with specific filtering requirements
12
+ - Dynamic labeling for cost metrics based on grouping dimension (e.g., "Region Total" vs "Service Total")
13
+ - Metadata support for usage metrics including grouped_by, metric type, and period information
14
+ - Optimized JSON serialization that only runs stringify_keys when needed
15
+ - Added Match_Option Validation
16
+
17
+ ### Changed
18
+ - Usage metrics now return clean nested structure instead of complex pandas tuples
19
+ - Old: `{("2025-01-01", "Amount"): {"EC2": 100}, ("2025-01-01", "Unit"): {"EC2": "Hours"}}`
20
+ - New: `{"GroupedUsage": {"2025-01-01": {"EC2": {"amount": 100, "unit": "Hours"}}}}`
21
+ - Improved UsageQuantity documentation to emphasize need for SERVICE + USAGE_TYPE filtering
22
+ - Enhanced error handling for metric data processing
23
+
24
+ ## [0.1.0] - 2025-06-01
25
+
26
+ ### Added
27
+ - Initial release of the Cost Explorer MCP Server
@@ -12,7 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1 AS uv
15
+ FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65 AS uv
16
16
 
17
17
  # Install the project into `/app`
18
18
  WORKDIR /app
@@ -46,7 +46,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
46
46
  # Make the directory just in case it doesn't exist
47
47
  RUN mkdir -p /root/.local
48
48
 
49
- FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1
49
+ FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65
50
50
 
51
51
  # Place executables in the environment at the front of the path and include other binaries
52
52
  ENV PATH="/app/.venv/bin:$PATH:/usr/sbin"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.cost-explorer-mcp-server
3
- Version: 0.0.4
3
+ Version: 0.0.5
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,6 +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: loguru>=0.7.0
25
26
  Requires-Dist: mcp[cli]>=1.6.0
26
27
  Requires-Dist: pandas>=2.2.3
27
28
  Requires-Dist: pydantic>=2.10.6
@@ -63,6 +64,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
63
64
 
64
65
  ## Installation
65
66
 
67
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
68
+
66
69
  Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
67
70
 
68
71
  ```json
@@ -34,6 +34,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
34
34
 
35
35
  ## Installation
36
36
 
37
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
38
+
37
39
  Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
38
40
 
39
41
  ```json
@@ -15,17 +15,47 @@
15
15
  """Helper functions for the Cost Explorer MCP server."""
16
16
 
17
17
  import boto3
18
- import logging
18
+ import os
19
19
  import re
20
+ import sys
20
21
  from datetime import datetime
22
+ from loguru import logger
21
23
  from typing import Any, Dict, Optional, Tuple
22
24
 
23
25
 
24
- # Set up logging
25
- logger = logging.getLogger(__name__)
26
+ # Configure Loguru logging
27
+ logger.remove()
28
+ logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
26
29
 
27
- # Initialize AWS Cost Explorer client
28
- ce = boto3.client('ce')
30
+ # Global client cache
31
+ _cost_explorer_client = None
32
+
33
+
34
+ def get_cost_explorer_client():
35
+ """Get Cost Explorer client with proper session management and caching.
36
+
37
+ Returns:
38
+ boto3.client: Configured Cost Explorer client (cached after first call)
39
+ """
40
+ global _cost_explorer_client
41
+
42
+ if _cost_explorer_client is None:
43
+ try:
44
+ # Read environment variables dynamically
45
+ aws_region = os.environ.get('AWS_REGION', 'us-east-1')
46
+ aws_profile = os.environ.get('AWS_PROFILE')
47
+
48
+ if aws_profile:
49
+ _cost_explorer_client = boto3.Session(
50
+ profile_name=aws_profile, region_name=aws_region
51
+ ).client('ce')
52
+ else:
53
+ _cost_explorer_client = boto3.Session(region_name=aws_region).client('ce')
54
+ except Exception as e:
55
+ logger.error(f'Error creating Cost Explorer client: {str(e)}')
56
+ raise
57
+
58
+ return _cost_explorer_client
29
59
 
30
60
 
31
61
  def validate_date_format(date_str: str) -> Tuple[bool, str]:
@@ -49,26 +79,79 @@ def validate_date_format(date_str: str) -> Tuple[bool, str]:
49
79
  return False, f"Invalid date '{date_str}': {str(e)}"
50
80
 
51
81
 
52
- def get_dimension_values(
53
- key: str, billing_period_start: str, billing_period_end: str
54
- ) -> Dict[str, Any]:
55
- """Get available values for a specific dimension."""
56
- # Validate date formats
57
- is_valid_start, error_start = validate_date_format(billing_period_start)
82
+ def format_date_for_api(date_str: str, granularity: str) -> str:
83
+ """Format date string appropriately for AWS Cost Explorer API based on granularity.
84
+
85
+ Args:
86
+ date_str: Date string in YYYY-MM-DD format
87
+ granularity: The granularity (DAILY, MONTHLY, HOURLY)
88
+
89
+ Returns:
90
+ Formatted date string appropriate for the API call
91
+ """
92
+ if granularity.upper() == 'HOURLY':
93
+ # For hourly granularity, AWS expects datetime format
94
+ # Convert YYYY-MM-DD to YYYY-MM-DDTHH:MM:SSZ
95
+ dt = datetime.strptime(date_str, '%Y-%m-%d')
96
+ return dt.strftime('%Y-%m-%dT00:00:00Z')
97
+ else:
98
+ # For DAILY and MONTHLY, use the original date format
99
+ return date_str
100
+
101
+
102
+ def validate_date_range(
103
+ start_date: str, end_date: str, granularity: Optional[str] = None
104
+ ) -> Tuple[bool, str]:
105
+ """Validate date range with format and logical checks.
106
+
107
+ Args:
108
+ start_date: The start date string in YYYY-MM-DD format
109
+ end_date: The end date string in YYYY-MM-DD format
110
+ granularity: Optional granularity to check specific constraints
111
+
112
+ Returns:
113
+ Tuple of (is_valid, error_message)
114
+ """
115
+ # Validate start date format
116
+ is_valid_start, error_start = validate_date_format(start_date)
58
117
  if not is_valid_start:
59
- return {'error': error_start}
118
+ return False, error_start
60
119
 
61
- is_valid_end, error_end = validate_date_format(billing_period_end)
120
+ # Validate end date format
121
+ is_valid_end, error_end = validate_date_format(end_date)
62
122
  if not is_valid_end:
63
- return {'error': error_end}
123
+ return False, error_end
124
+
125
+ # Validate date range logic
126
+ start_dt = datetime.strptime(start_date, '%Y-%m-%d')
127
+ end_dt = datetime.strptime(end_date, '%Y-%m-%d')
128
+ if start_dt > end_dt:
129
+ return False, f"Start date '{start_date}' cannot be after end date '{end_date}'"
130
+
131
+ # Validate granularity-specific constraints
132
+ if granularity and granularity.upper() == 'HOURLY':
133
+ # HOURLY granularity supports maximum 14 days
134
+ date_diff = (end_dt - start_dt).days
135
+ if date_diff > 14:
136
+ return (
137
+ False,
138
+ f'HOURLY granularity supports a maximum of 14 days. Current range is {date_diff} days ({start_date} to {end_date}). Please use a shorter date range.',
139
+ )
64
140
 
65
- # Validate date range
66
- if billing_period_start > billing_period_end:
67
- return {
68
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
69
- }
141
+ return True, ''
142
+
143
+
144
+ def get_dimension_values(
145
+ key: str, billing_period_start: str, billing_period_end: str
146
+ ) -> Dict[str, Any]:
147
+ """Get available values for a specific dimension."""
148
+ # Validate date range (no granularity constraint for dimension values)
149
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
150
+ if not is_valid:
151
+ return {'error': error_message}
70
152
 
71
153
  try:
154
+ ce = get_cost_explorer_client()
72
155
  response = ce.get_dimension_values(
73
156
  TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
74
157
  Dimension=key.upper(),
@@ -77,7 +160,9 @@ def get_dimension_values(
77
160
  values = [value['Value'] for value in dimension_values]
78
161
  return {'dimension': key.upper(), 'values': values}
79
162
  except Exception as e:
80
- logger.error(f'Error getting dimension values: {e}')
163
+ logger.error(
164
+ f'Error getting dimension values for {key.upper()} ({billing_period_start} to {billing_period_end}): {e}'
165
+ )
81
166
  return {'error': str(e)}
82
167
 
83
168
 
@@ -85,22 +170,13 @@ def get_tag_values(
85
170
  tag_key: str, billing_period_start: str, billing_period_end: str
86
171
  ) -> Dict[str, Any]:
87
172
  """Get available values for a specific tag key."""
88
- # Validate date formats
89
- is_valid_start, error_start = validate_date_format(billing_period_start)
90
- if not is_valid_start:
91
- return {'error': error_start}
92
-
93
- is_valid_end, error_end = validate_date_format(billing_period_end)
94
- if not is_valid_end:
95
- return {'error': error_end}
96
-
97
- # Validate date range
98
- if billing_period_start > billing_period_end:
99
- return {
100
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
101
- }
173
+ # Validate date range (no granularity constraint for tag values)
174
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
175
+ if not is_valid:
176
+ return {'error': error_message}
102
177
 
103
178
  try:
179
+ ce = get_cost_explorer_client()
104
180
  response = ce.get_tags(
105
181
  TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
106
182
  TagKey=tag_key,
@@ -108,10 +184,38 @@ def get_tag_values(
108
184
  tag_values = response['Tags']
109
185
  return {'tag_key': tag_key, 'values': tag_values}
110
186
  except Exception as e:
111
- logger.error(f'Error getting tag values: {e}')
187
+ logger.error(
188
+ f'Error getting tag values for {tag_key} ({billing_period_start} to {billing_period_end}): {e}'
189
+ )
112
190
  return {'error': str(e)}
113
191
 
114
192
 
193
+ def validate_match_options(match_options: list, filter_type: str) -> Dict[str, Any]:
194
+ """Validate MatchOptions based on filter type.
195
+
196
+ Args:
197
+ match_options: List of match options to validate
198
+ filter_type: Type of filter ('Dimensions', 'Tags', 'CostCategories')
199
+
200
+ Returns:
201
+ Empty dictionary if valid, or an error dictionary
202
+ """
203
+ if filter_type == 'Dimensions':
204
+ valid_options = ['EQUALS', 'CASE_SENSITIVE']
205
+ elif filter_type in ['Tags', 'CostCategories']:
206
+ valid_options = ['EQUALS', 'ABSENT', 'CASE_SENSITIVE']
207
+ else:
208
+ return {'error': f'Unknown filter type: {filter_type}'}
209
+
210
+ for option in match_options:
211
+ if option not in valid_options:
212
+ return {
213
+ 'error': f"Invalid MatchOption '{option}' for {filter_type}. Valid values are: {valid_options}"
214
+ }
215
+
216
+ return {}
217
+
218
+
115
219
  def validate_expression(
116
220
  expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
117
221
  ) -> Dict[str, Any]:
@@ -125,20 +229,10 @@ def validate_expression(
125
229
  Returns:
126
230
  Empty dictionary if valid, or an error dictionary
127
231
  """
128
- # Validate date formats
129
- is_valid_start, error_start = validate_date_format(billing_period_start)
130
- if not is_valid_start:
131
- return {'error': error_start}
132
-
133
- is_valid_end, error_end = validate_date_format(billing_period_end)
134
- if not is_valid_end:
135
- return {'error': error_end}
136
-
137
- # Validate date range
138
- if billing_period_start > billing_period_end:
139
- return {
140
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
141
- }
232
+ # Validate date range (no granularity constraint for filter validation)
233
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
234
+ if not is_valid:
235
+ return {'error': error_message}
142
236
 
143
237
  try:
144
238
  if 'Dimensions' in expression:
@@ -152,6 +246,11 @@ def validate_expression(
152
246
  'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
153
247
  }
154
248
 
249
+ # Validate MatchOptions for Dimensions
250
+ match_options_result = validate_match_options(dimension['MatchOptions'], 'Dimensions')
251
+ if 'error' in match_options_result:
252
+ return match_options_result
253
+
155
254
  dimension_key = dimension['Key']
156
255
  dimension_values = dimension['Values']
157
256
  valid_values_response = get_dimension_values(
@@ -171,6 +270,11 @@ def validate_expression(
171
270
  if 'Key' not in tag or 'Values' not in tag or 'MatchOptions' not in tag:
172
271
  return {'error': 'Tags filter must include "Key", "Values", and "MatchOptions".'}
173
272
 
273
+ # Validate MatchOptions for Tags
274
+ match_options_result = validate_match_options(tag['MatchOptions'], 'Tags')
275
+ if 'error' in match_options_result:
276
+ return match_options_result
277
+
174
278
  tag_key = tag['Key']
175
279
  tag_values = tag['Values']
176
280
  valid_tag_values_response = get_tag_values(
@@ -196,6 +300,13 @@ def validate_expression(
196
300
  'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
197
301
  }
198
302
 
303
+ # Validate MatchOptions for CostCategories
304
+ match_options_result = validate_match_options(
305
+ cost_category['MatchOptions'], 'CostCategories'
306
+ )
307
+ if 'error' in match_options_result:
308
+ return match_options_result
309
+
199
310
  logical_operators = ['And', 'Or', 'Not']
200
311
  logical_count = sum(1 for op in logical_operators if op in expression)
201
312