awslabs.cost-explorer-mcp-server 0.0.1__tar.gz → 0.0.2__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 (26) hide show
  1. awslabs_cost_explorer_mcp_server-0.0.2/Dockerfile +82 -0
  2. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/PKG-INFO +13 -17
  3. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/README.md +12 -16
  4. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/awslabs/__init__.py +1 -1
  5. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/awslabs/cost_explorer_mcp_server/__init__.py +3 -3
  6. awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/helpers.py +259 -0
  7. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/awslabs/cost_explorer_mcp_server/server.py +180 -112
  8. awslabs_cost_explorer_mcp_server-0.0.2/docker-healthcheck.sh +12 -0
  9. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/pyproject.toml +2 -2
  10. awslabs_cost_explorer_mcp_server-0.0.2/tests/conftest.py +93 -0
  11. awslabs_cost_explorer_mcp_server-0.0.2/tests/test_helpers.py +282 -0
  12. awslabs_cost_explorer_mcp_server-0.0.2/tests/test_server.py +427 -0
  13. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/uv.lock +1 -1
  14. awslabs_cost_explorer_mcp_server-0.0.1/Dockerfile +0 -26
  15. awslabs_cost_explorer_mcp_server-0.0.1/awslabs/cost_explorer_mcp_server/helpers.py +0 -221
  16. awslabs_cost_explorer_mcp_server-0.0.1/docker-healthcheck.sh +0 -9
  17. awslabs_cost_explorer_mcp_server-0.0.1/tests/conftest.py +0 -160
  18. awslabs_cost_explorer_mcp_server-0.0.1/tests/test_helpers.py +0 -240
  19. awslabs_cost_explorer_mcp_server-0.0.1/tests/test_server.py +0 -373
  20. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/.gitignore +0 -0
  21. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/.pre-commit-config.yaml +0 -0
  22. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/.python-version +0 -0
  23. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/CHANGELOG.md +0 -0
  24. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/LICENSE +0 -0
  25. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/NOTICE +0 -0
  26. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.2}/tests/__init__.py +0 -0
@@ -0,0 +1,82 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
4
+ # with the License. A copy of the License is located at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
9
+ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
10
+ # and limitations under the License.
11
+
12
+ FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1 AS uv
13
+
14
+ # Install the project into `/app`
15
+ WORKDIR /app
16
+
17
+ # Enable bytecode compilation
18
+ ENV UV_COMPILE_BYTECODE=1
19
+
20
+ # Copy from the cache instead of linking since it's a mounted volume
21
+ ENV UV_LINK_MODE=copy
22
+
23
+ # Prefer the system python
24
+ ENV UV_PYTHON_PREFERENCE=only-system
25
+
26
+ # Run without updating the uv.lock file like running with `--frozen`
27
+ ENV UV_FROZEN=true
28
+
29
+ # Copy the required files first
30
+ COPY pyproject.toml uv.lock ./
31
+
32
+ # Install the project's dependencies using the lockfile and settings
33
+ RUN --mount=type=cache,target=/root/.cache/uv \
34
+ pip install uv && \
35
+ uv sync --frozen --no-install-project --no-dev --no-editable
36
+
37
+ # Then, add the rest of the project source code and install it
38
+ # Installing separately from its dependencies allows optimal layer caching
39
+ COPY . /app
40
+ RUN --mount=type=cache,target=/root/.cache/uv \
41
+ uv sync --frozen --no-dev --no-editable
42
+
43
+ # Make the directory just in case it doesn't exist
44
+ RUN mkdir -p /root/.local
45
+
46
+ FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1
47
+
48
+ # Place executables in the environment at the front of the path and include other binaries
49
+ ENV PATH="/app/.venv/bin:$PATH:/usr/sbin"
50
+
51
+ # Set environment variables
52
+ ENV PYTHONUNBUFFERED=1 \
53
+ PYTHONDONTWRITEBYTECODE=1 \
54
+ FASTMCP_LOG_LEVEL=INFO
55
+
56
+ # Install lsof for the healthcheck
57
+ # Install other tools as needed for the MCP server
58
+ # Add non-root user and ability to change directory into /root
59
+ RUN yum update -y && \
60
+ yum install -y lsof && \
61
+ yum clean all -y && \
62
+ rm -rf /var/cache/yum && \
63
+ groupadd --force --system app && \
64
+ useradd app -g app -d /app && \
65
+ chmod o+x /root
66
+
67
+ # Get the project from the uv layer
68
+ COPY --from=uv --chown=app:app /root/.local /root/.local
69
+ COPY --from=uv --chown=app:app /app/.venv /app/.venv
70
+
71
+ # Get healthcheck script
72
+ COPY ./docker-healthcheck.sh /usr/local/bin/docker-healthcheck.sh
73
+ RUN chmod +x /usr/local/bin/docker-healthcheck.sh
74
+
75
+ # Run as non-root
76
+ USER app
77
+
78
+ # Health check
79
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "docker-healthcheck.sh" ]
80
+
81
+ # Use entrypoint instead of CMD
82
+ ENTRYPOINT ["awslabs.cost-explorer-mcp-server"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.cost-explorer-mcp-server
3
- Version: 0.0.1
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
- Example IAM policy:
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
 
@@ -98,6 +98,17 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
98
98
  ```
99
99
 
100
100
  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.
101
+ ## Cost Considerations
102
+
103
+ **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.
104
+
105
+ - **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.
106
+ - 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
107
+ - Complex queries with multiple filters or large date ranges may result in multiple API calls
108
+
109
+ For current pricing information, please refer to the [AWS Cost Explorer Pricing page](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
110
+
111
+
101
112
  ## Security Considerations
102
113
 
103
114
  ### Required IAM Permissions
@@ -106,22 +117,7 @@ The following IAM permissions are required for this MCP server:
106
117
  - ce:GetDimensionValues
107
118
  - ce:GetTags
108
119
 
109
- Example IAM policy:
110
- json
111
- {
112
- "Version": "2012-10-17",
113
- "Statement": [
114
- {
115
- "Effect": "Allow",
116
- "Action": [
117
- "ce:GetCostAndUsage",
118
- "ce:GetDimensionValues",
119
- "ce:GetTags"
120
- ],
121
- "Resource": "*"
122
- }
123
- ]
124
- }
120
+
125
121
 
126
122
  ## Available Tools
127
123
 
@@ -1,3 +1,3 @@
1
1
  """
2
2
  AWS Labs Cost Explorer MCP Server package.
3
- """
3
+ """
@@ -1,6 +1,6 @@
1
- """
2
- Cost Explorer MCP Server module.
1
+ """Cost Explorer MCP Server module.
3
2
 
4
3
  This module provides MCP tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
5
4
  """
6
- __version__ = '0.0.0'
5
+
6
+ __version__ = "0.0.0"
@@ -0,0 +1,259 @@
1
+ """Helper functions for the Cost Explorer MCP server."""
2
+
3
+ import boto3
4
+ import logging
5
+ import re
6
+ from datetime import datetime
7
+ from typing import Any, Dict, Tuple
8
+
9
+
10
+ # Set up logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Initialize AWS Cost Explorer client
14
+ ce = boto3.client("ce")
15
+
16
+
17
+ def validate_date_format(date_str: str) -> Tuple[bool, str]:
18
+ """Validate that a date string is in YYYY-MM-DD format and is a valid date.
19
+
20
+ Args:
21
+ date_str: The date string to validate
22
+
23
+ Returns:
24
+ Tuple of (is_valid, error_message)
25
+ """
26
+ # Check format with regex
27
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
28
+ return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
29
+
30
+ # Check if it's a valid date
31
+ try:
32
+ datetime.strptime(date_str, "%Y-%m-%d")
33
+ return True, ""
34
+ except ValueError as e:
35
+ return False, f"Invalid date '{date_str}': {str(e)}"
36
+
37
+
38
+ def get_dimension_values(
39
+ key: str, billing_period_start: str, billing_period_end: str
40
+ ) -> Dict[str, Any]:
41
+ """Get available values for a specific dimension."""
42
+ # Validate date formats
43
+ is_valid_start, error_start = validate_date_format(billing_period_start)
44
+ if not is_valid_start:
45
+ return {"error": error_start}
46
+
47
+ is_valid_end, error_end = validate_date_format(billing_period_end)
48
+ if not is_valid_end:
49
+ return {"error": error_end}
50
+
51
+ # Validate date range
52
+ if billing_period_start > billing_period_end:
53
+ return {
54
+ "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
55
+ }
56
+
57
+ try:
58
+ response = ce.get_dimension_values(
59
+ TimePeriod={"Start": billing_period_start, "End": billing_period_end},
60
+ Dimension=key.upper(),
61
+ )
62
+ dimension_values = response["DimensionValues"]
63
+ values = [value["Value"] for value in dimension_values]
64
+ return {"dimension": key.upper(), "values": values}
65
+ except Exception as e:
66
+ logger.error(f"Error getting dimension values: {e}")
67
+ return {"error": str(e)}
68
+
69
+
70
+ def get_tag_values(
71
+ tag_key: str, billing_period_start: str, billing_period_end: str
72
+ ) -> Dict[str, Any]:
73
+ """Get available values for a specific tag key."""
74
+ # Validate date formats
75
+ is_valid_start, error_start = validate_date_format(billing_period_start)
76
+ if not is_valid_start:
77
+ return {"error": error_start}
78
+
79
+ is_valid_end, error_end = validate_date_format(billing_period_end)
80
+ if not is_valid_end:
81
+ return {"error": error_end}
82
+
83
+ # Validate date range
84
+ if billing_period_start > billing_period_end:
85
+ return {
86
+ "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
87
+ }
88
+
89
+ try:
90
+ response = ce.get_tags(
91
+ TimePeriod={"Start": billing_period_start, "End": billing_period_end},
92
+ TagKey=tag_key,
93
+ )
94
+ tag_values = response["Tags"]
95
+ return {"tag_key": tag_key, "values": tag_values}
96
+ except Exception as e:
97
+ logger.error(f"Error getting tag values: {e}")
98
+ return {"error": str(e)}
99
+
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.
105
+
106
+ Args:
107
+ expression: The filter expression to validate
108
+ billing_period_start: Start date of the billing period
109
+ billing_period_end: End date of the billing period
110
+
111
+ Returns:
112
+ Empty dictionary if valid, or an error dictionary
113
+ """
114
+ # Validate date formats
115
+ is_valid_start, error_start = validate_date_format(billing_period_start)
116
+ if not is_valid_start:
117
+ return {"error": error_start}
118
+
119
+ is_valid_end, error_end = validate_date_format(billing_period_end)
120
+ if not is_valid_end:
121
+ return {"error": error_end}
122
+
123
+ # Validate date range
124
+ if billing_period_start > billing_period_end:
125
+ return {
126
+ "error": f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
127
+ }
128
+
129
+ try:
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
+ }
140
+
141
+ dimension_key = dimension["Key"]
142
+ dimension_values = dimension["Values"]
143
+ valid_values_response = get_dimension_values(
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"]
149
+ for value in dimension_values:
150
+ if value not in valid_values:
151
+ return {
152
+ "error": f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
153
+ }
154
+
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".'}
159
+
160
+ tag_key = tag["Key"]
161
+ tag_values = tag["Values"]
162
+ valid_tag_values_response = get_tag_values(
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"]
168
+ for value in tag_values:
169
+ if value not in valid_tag_values:
170
+ return {
171
+ "error": f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
172
+ }
173
+
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
+ }
184
+
185
+ logical_operators = ["And", "Or", "Not"]
186
+ logical_count = sum(1 for op in logical_operators if op in expression)
187
+
188
+ if logical_count > 1:
189
+ return {
190
+ "error": "Only one logical operator (And, Or, Not) is allowed per expression in filter parameter."
191
+ }
192
+
193
+ if logical_count == 0 and len(expression) > 1:
194
+ return {
195
+ "error": "Filter parameter with multiple expressions require a logical operator (And, Or, Not)."
196
+ }
197
+
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"]:
202
+ result = validate_expression(
203
+ sub_expression, billing_period_start, billing_period_end
204
+ )
205
+ if "error" in result:
206
+ return result
207
+
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"]:
212
+ result = validate_expression(
213
+ sub_expression, billing_period_start, billing_period_end
214
+ )
215
+ if "error" in result:
216
+ return result
217
+
218
+ if "Not" in expression:
219
+ if not isinstance(expression["Not"], dict):
220
+ return {"error": "Not expression must be a single expression."}
221
+ result = validate_expression(
222
+ expression["Not"], billing_period_start, billing_period_end
223
+ )
224
+ if "error" in result:
225
+ return result
226
+
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
+ }
233
+
234
+ return {}
235
+ except Exception as e:
236
+ return {"error": f"Error validating expression: {str(e)}"}
237
+
238
+
239
+ def validate_group_by(group_by: Dict[str, Any]) -> Dict[str, Any]:
240
+ """Validate the group_by parameter.
241
+
242
+ Args:
243
+ group_by: The group_by dictionary to validate
244
+
245
+ Returns:
246
+ Empty dictionary if valid, or an error dictionary
247
+ """
248
+ 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.'}
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
+
257
+ return {}
258
+ except Exception as e:
259
+ return {"error": f"Error validating group_by: {str(e)}"}