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.
- awslabs_cost_explorer_mcp_server-0.0.5/CHANGELOG.md +27 -0
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile +2 -2
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/PKG-INFO +4 -1
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/README.md +2 -0
- {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
- {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
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/pyproject.toml +2 -1
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/conftest.py +48 -8
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/test_helpers.py +264 -11
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/tests/test_server.py +545 -52
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/uv.lock +25 -1
- awslabs_cost_explorer_mcp_server-0.0.4/.pre-commit-config.yaml +0 -14
- awslabs_cost_explorer_mcp_server-0.0.4/CHANGELOG.md +0 -11
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/.gitignore +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/.python-version +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/NOTICE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/awslabs/__init__.py +0 -0
- {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
- {awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/docker-healthcheck.sh +0 -0
- {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
|
{awslabs_cost_explorer_mcp_server-0.0.4 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile
RENAMED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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
|
+
[](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
|
+
[](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
|
|
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
|
-
#
|
|
25
|
-
logger
|
|
26
|
+
# Configure Loguru logging
|
|
27
|
+
logger.remove()
|
|
28
|
+
logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
|
|
26
29
|
|
|
27
|
-
#
|
|
28
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
118
|
+
return False, error_start
|
|
60
119
|
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
|
89
|
-
|
|
90
|
-
if not
|
|
91
|
-
return {'error':
|
|
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(
|
|
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
|
|
129
|
-
|
|
130
|
-
if not
|
|
131
|
-
return {'error':
|
|
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
|
|