Skip to content

Commit cdd87a8

Browse files
authored
fix(ri/sp-performance): omit Granularity when GroupBy is set (#4479)
Cost Explorer's GetReservationCoverage, GetReservationUtilization, and GetSavingsPlansCoverage treat Granularity and GroupBy as mutually exclusive. The coverage/utilization request builders always set Granularity, so any grouped query failed with a deterministic ValidationException (e.g. 'Granularity is not supported when specifying groupBy'). Send Granularity only when the request is not grouped, across get_reservation_coverage, get_reservation_utilization, and get_savings_plans_coverage. Document the mutual exclusivity in both tool descriptions and granularity docstrings so the agent avoids sending both. Update tests to assert Granularity is omitted when GroupBy is present.
1 parent 18f15a9 commit cdd87a8

4 files changed

Lines changed: 46 additions & 11 deletions

File tree

src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/ri_performance_tools.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@
6060
- SERVICE: AWS service (EC2, RDS, etc.)
6161
- TENANCY: Instance tenancy (default, dedicated)
6262
63-
Reservation utilization can only be grouped by SUBSCRIPTION_ID.""",
63+
Reservation utilization can only be grouped by SUBSCRIPTION_ID.
64+
65+
IMPORTANT: `granularity` and `group_by` are mutually exclusive. If `group_by` is
66+
provided, `granularity` is ignored (the Cost Explorer API does not allow both for
67+
GetReservationCoverage or GetReservationUtilization). Provide one or the other,
68+
not both.""",
6469
)
6570
async def ri_performance(
6671
ctx: Context,
@@ -81,7 +86,7 @@ async def ri_performance(
8186
operation: The operation to perform: 'get_reservation_coverage' or 'get_reservation_utilization'
8287
start_date: Start date in YYYY-MM-DD format (inclusive). Defaults to 30 days ago if not provided.
8388
end_date: End date in YYYY-MM-DD format (exclusive). Defaults to today if not provided.
84-
granularity: Time granularity of the data (DAILY or MONTHLY). Defaults to DAILY.
89+
granularity: Time granularity of the data (DAILY or MONTHLY). Defaults to DAILY. Ignored when group_by is provided (granularity and group_by are mutually exclusive).
8590
metrics: List of metrics to retrieve for coverage as a JSON string (e.g., '["HoursCoverage", "CostCoverage"]'). Defaults to all metrics.
8691
group_by: Optional grouping of results as a JSON string. For coverage, supports multiple dimensions. For utilization, only supports SUBSCRIPTION_ID.
8792
filter: Optional filter to apply to the results as a JSON string, such as filtering by service, region, or instance type.
@@ -174,9 +179,8 @@ async def get_reservation_coverage(
174179
)
175180

176181
# Prepare the request parameters
177-
request_params = {
182+
request_params: Dict[str, Any] = {
178183
'TimePeriod': {'Start': start, 'End': end},
179-
'Granularity': granularity,
180184
}
181185

182186
# Add optional parameters if provided
@@ -186,6 +190,13 @@ async def get_reservation_coverage(
186190
if group_by:
187191
request_params['GroupBy'] = parse_json(group_by, 'group_by')
188192

193+
# GetReservationCoverage treats Granularity and GroupBy as mutually
194+
# exclusive: "Granularity is not supported when specifying groupBy".
195+
# Only send Granularity when the request is not grouped, otherwise the
196+
# API returns a ValidationException.
197+
if not group_by:
198+
request_params['Granularity'] = granularity
199+
189200
if filter_expr:
190201
request_params['Filter'] = parse_json(filter_expr, 'filter')
191202

@@ -295,15 +306,21 @@ async def get_reservation_utilization(
295306
)
296307

297308
# Prepare the request parameters
298-
request_params = {
309+
request_params: Dict[str, Any] = {
299310
'TimePeriod': {'Start': start, 'End': end},
300-
'Granularity': granularity,
301311
}
302312

303313
# Add optional parameters if provided
304314
if group_by:
305315
request_params['GroupBy'] = parse_json(group_by, 'group_by')
306316

317+
# GetReservationUtilization treats Granularity and GroupBy as mutually
318+
# exclusive: "If GroupBy is set, Granularity can't be set". Only send
319+
# Granularity when the request is not grouped, otherwise the API returns
320+
# a ValidationException.
321+
if not group_by:
322+
request_params['Granularity'] = granularity
323+
307324
if filter_expr:
308325
request_params['Filter'] = parse_json(filter_expr, 'filter')
309326

src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/sp_performance_tools.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@
4444
4545
1. get_savings_plans_coverage: Shows how much of your eligible usage is covered by Savings Plans
4646
2. get_savings_plans_utilization: Shows overall utilization metrics for your Savings Plans
47-
3. get_savings_plans_utilization_details: Shows detailed per-Savings Plan utilization""",
47+
3. get_savings_plans_utilization_details: Shows detailed per-Savings Plan utilization
48+
49+
IMPORTANT: For get_savings_plans_coverage, `granularity` and `group_by` are mutually
50+
exclusive. If `group_by` is provided, `granularity` is ignored (the Cost Explorer
51+
GetSavingsPlansCoverage API does not allow both). Valid `group_by` dimensions for
52+
coverage are SERVICE, REGION, or INSTANCE_FAMILY.""",
4853
)
4954
async def sp_performance(
5055
ctx: Context,
@@ -64,7 +69,7 @@ async def sp_performance(
6469
operation: The operation to perform: 'get_savings_plans_coverage', 'get_savings_plans_utilization', or 'get_savings_plans_utilization_details'
6570
start_date: Start date in YYYY-MM-DD format (inclusive). Defaults to 30 days ago if not provided.
6671
end_date: End date in YYYY-MM-DD format (exclusive). Defaults to today if not provided.
67-
granularity: Time granularity of the data (DAILY or MONTHLY). Defaults to DAILY.
72+
granularity: Time granularity of the data (DAILY or MONTHLY). Defaults to DAILY. For coverage, ignored when group_by is provided (granularity and group_by are mutually exclusive).
6873
metrics: List of metrics to retrieve as a JSON string. For coverage, only 'SpendCoveredBySavingsPlans' is valid.
6974
group_by: Optional grouping of results as a JSON string. For coverage, supports SERVICE, REGION, or INSTANCE_FAMILY.
7075
filter: Optional filter to apply to the results as a JSON string.
@@ -145,13 +150,18 @@ async def get_savings_plans_coverage(
145150
# Prepare the request parameters
146151
request_params = {
147152
'TimePeriod': {'Start': start, 'End': end},
148-
'Granularity': granularity,
149153
'Metrics': metrics_list,
150154
}
151155

152-
# Add optional parameters if provided
156+
# Add optional parameters if provided.
157+
# GetSavingsPlansCoverage treats Granularity and GroupBy as mutually
158+
# exclusive: "Granularity can't be set if GroupBy is set". Only send
159+
# Granularity when the request is not grouped, otherwise the API returns
160+
# a ValidationException.
153161
if group_by:
154162
request_params['GroupBy'] = parse_json(group_by, 'group_by')
163+
else:
164+
request_params['Granularity'] = granularity
155165

156166
if filter_expr:
157167
request_params['Filter'] = parse_json(filter_expr, 'filter')

src/billing-cost-management-mcp-server/tests/tools/test_ri_performance_tools.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,9 @@ async def test_get_reservation_coverage_with_options(
407407
assert request_params['Filter'] == mock_filter
408408
assert request_params['SortBy'] == mock_sort_by
409409
assert request_params['MaxResults'] == 50
410+
# Granularity and GroupBy are mutually exclusive for GetReservationCoverage:
411+
# when GroupBy is set, Granularity must not be sent.
412+
assert 'Granularity' not in request_params
410413

411414
assert result['status'] == 'success'
412415

@@ -550,6 +553,9 @@ async def test_get_reservation_utilization_with_options(
550553
assert request_params['Filter'] == mock_filter
551554
assert request_params['SortBy'] == mock_sort_by
552555
assert request_params['MaxResults'] == 50
556+
# Granularity and GroupBy are mutually exclusive for GetReservationUtilization:
557+
# when GroupBy is set, Granularity must not be sent.
558+
assert 'Granularity' not in request_params
553559

554560
assert result['status'] == 'success'
555561

src/billing-cost-management-mcp-server/tests/tools/test_sp_performance_tools.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,9 @@ async def test_get_savings_plans_coverage_with_options(
627627
assert request_params['Metrics'] == mock_metrics
628628
assert request_params['GroupBy'] == mock_group_by
629629
assert request_params['Filter'] == mock_filter
630-
assert request_params['Granularity'] == 'MONTHLY'
630+
# Granularity and GroupBy are mutually exclusive for GetSavingsPlansCoverage:
631+
# when GroupBy is set, Granularity must not be sent.
632+
assert 'Granularity' not in request_params
631633

632634
assert result['status'] == 'success'
633635

0 commit comments

Comments
 (0)