Skip to content

Commit 4eee142

Browse files
fix(billing-cost-management-mcp-server): refuse billing_view_arn where AWS ignores it (#4936)
GetSavingsPlansUtilization has no BillingViewArn request member (API reference and botocore model), so cost-explorer dropped the argument without saying so and returned utilization for the calling account's primary billing view under the caller's requested view. GetDimensionValues does take BillingViewArn, but in the RESERVATIONS and SAVINGS_PLANS contexts AWS does not apply it: the values are the same as without the view, including accounts outside it, and a well-formed ARN that names no view is accepted. Only the COST_AND_USAGE context is scoped to the view. The tool now returns a validation_error with a hint in both cases, and the description names the operations that accept billing_view_arn (getDimensionValues in the COST_AND_USAGE context only) and the ones that refuse it, instead of "most operations".
1 parent 4bc83ec commit 4eee142

2 files changed

Lines changed: 220 additions & 5 deletions

File tree

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

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,14 @@ def _with_example_dates(description: str) -> str:
263263
values present in the account.
264264
265265
BILLING VIEW SUPPORT:
266-
Most Cost Explorer operations support an optional billing_view_arn parameter that allows you to scope
267-
your query to a specific billing view. If not provided, the query defaults to the account's primary
268-
billing view. You can use the list-billing-views and get-billing-view tools upon request to discover
269-
available billing views and their ARNs.
266+
billing_view_arn is accepted by getCostAndUsage, getCostAndUsageWithResources, getDimensionValues
267+
(COST_AND_USAGE context only), getCostForecast, getUsageForecast, getTagsOrValues and
268+
getCostCategories, and scopes the query to that billing view. If not provided, the query defaults
269+
to the account's primary billing view. getSavingsPlansUtilization (the AWS API has no billing view
270+
parameter) and getDimensionValues with context RESERVATIONS or SAVINGS_PLANS (AWS does not apply
271+
the view there) do not accept billing_view_arn; setting it returns a validation_error. You can use
272+
the list-billing-views and get-billing-view tools upon request to discover available billing views
273+
and their ARNs.
270274
271275
Billing view types:
272276
- PRIMARY: Default view with all cost data for the account/org. Use for standard cost analysis.
@@ -331,7 +335,10 @@ async def cost_explorer(
331335
prediction_interval_level: Confidence level for forecasts
332336
tag_key: Tag key to get values for
333337
cost_category_name: Cost category to get values for
334-
billing_view_arn: Optional ARN of a billing view to scope the query
338+
billing_view_arn: Optional ARN of a billing view to scope the query. Rejected for
339+
getSavingsPlansUtilization, whose API has no billing view parameter, and for
340+
getDimensionValues with context RESERVATIONS or SAVINGS_PLANS, where AWS does not
341+
apply the view
335342
context: The context for (getDimensionValues), which can be COST_AND_USAGE (the default),
336343
RESERVATIONS, or SAVINGS_PLANS. SAVINGS_PLANS scopes values to the plans an account owns.
337344
@@ -427,6 +434,33 @@ async def cost_explorer(
427434
),
428435
)
429436

437+
# In the RESERVATIONS and SAVINGS_PLANS contexts AWS accepts BillingViewArn but does
438+
# not apply the view: the values are the same as without it, including accounts
439+
# outside the view, and a well-formed ARN that names no view is not rejected. Refuse
440+
# it rather than return unscoped values the caller believes are scoped to their view.
441+
# Match the exact spelling: AWS rejects any other case of context on its own.
442+
if billing_view_arn and context in ('RESERVATIONS', 'SAVINGS_PLANS'):
443+
return format_response(
444+
'error',
445+
{
446+
'message': (
447+
'billing_view_arn is not supported for getDimensionValues with '
448+
f'context={context}: AWS does not apply the billing view in this '
449+
"context, so the values cover the calling account's own data (its "
450+
'primary billing view), including accounts outside the view.'
451+
)
452+
},
453+
error_type='validation_error',
454+
operation=operation,
455+
service=COST_EXPLORER_SERVICE_NAME,
456+
hint=(
457+
'Remove billing_view_arn to list the values for the calling '
458+
"account's own data, and say that they are not scoped to the requested "
459+
'view: they can include accounts the view does not cover. Do **NOT** '
460+
'retry with the same billing_view_arn.'
461+
),
462+
)
463+
430464
return await get_dimension_values(
431465
ctx,
432466
ce_client,
@@ -515,6 +549,32 @@ async def cost_explorer(
515549
)
516550

517551
elif operation == 'getSavingsPlansUtilization':
552+
# GetSavingsPlansUtilization has no BillingViewArn, so the ARN cannot be sent.
553+
# Refuse it: dropping it would return primary-view data the caller believes is
554+
# scoped to their view.
555+
if billing_view_arn:
556+
return format_response(
557+
'error',
558+
{
559+
'message': (
560+
'billing_view_arn is not supported for getSavingsPlansUtilization: '
561+
'the GetSavingsPlansUtilization API has no billing view parameter, '
562+
"so it always reports the calling account's own data (its primary "
563+
'billing view).'
564+
)
565+
},
566+
error_type='validation_error',
567+
operation=operation,
568+
service=COST_EXPLORER_SERVICE_NAME,
569+
hint=(
570+
'Remove billing_view_arn to get Savings Plans utilization for the calling '
571+
"account's own data, and say that it is not scoped to the requested view: "
572+
'it can cover more accounts than the view (the whole organization when '
573+
'called from a management account). Do **NOT** retry with the same '
574+
'billing_view_arn.'
575+
),
576+
)
577+
518578
return await get_savings_plans_utilization(
519579
ctx, ce_client, start_date, end_date, granularity, filter
520580
)

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,3 +2034,158 @@ async def test_description_states_savings_plans_data_lag():
20342034
)
20352035
assert 'an unknown SAVINGS_PLANS_TYPE matches nothing' in tool.description
20362036
assert 'report that no data is available for that window (not zero)' in tool.description
2037+
2038+
2039+
@pytest.mark.asyncio
2040+
async def test_ce_get_savings_plans_utilization_rejects_billing_view_arn(
2041+
mock_context, sample_billing_view_arn
2042+
):
2043+
"""GetSavingsPlansUtilization has no BillingViewArn, so the tool refuses the ARN.
2044+
2045+
Dropping it would return utilization for the primary view labelled as the caller's view.
2046+
"""
2047+
ce_mod = _reload_ce_with_identity_decorator()
2048+
real_fn = ce_mod.cost_explorer # type: ignore
2049+
2050+
with (
2051+
patch.object(ce_mod, 'create_aws_client') as mock_create_client,
2052+
patch.object(ce_mod, 'get_savings_plans_utilization', new_callable=AsyncMock) as mock_impl,
2053+
):
2054+
mock_create_client.return_value = MagicMock()
2055+
2056+
res = await real_fn( # type: ignore
2057+
mock_context,
2058+
operation='getSavingsPlansUtilization',
2059+
start_date='2024-01-01',
2060+
end_date='2024-02-01',
2061+
granularity='MONTHLY',
2062+
billing_view_arn=sample_billing_view_arn,
2063+
)
2064+
2065+
assert res['status'] == 'error'
2066+
assert res['error_type'] == 'validation_error'
2067+
assert 'billing_view_arn' in res['message']
2068+
assert 'getSavingsPlansUtilization' in res['message']
2069+
assert 'billing_view_arn' in res['hint']
2070+
mock_impl.assert_not_awaited()
2071+
2072+
2073+
@pytest.mark.asyncio
2074+
async def test_ce_get_savings_plans_utilization_empty_billing_view_arn_is_absent(mock_context):
2075+
"""An empty billing_view_arn means no view, as for the operations that send it."""
2076+
ce_mod = _reload_ce_with_identity_decorator()
2077+
real_fn = ce_mod.cost_explorer # type: ignore
2078+
2079+
with (
2080+
patch.object(ce_mod, 'create_aws_client') as mock_create_client,
2081+
patch.object(ce_mod, 'get_savings_plans_utilization', new_callable=AsyncMock) as mock_impl,
2082+
):
2083+
mock_create_client.return_value = MagicMock()
2084+
mock_impl.return_value = {'status': 'success', 'data': {}}
2085+
2086+
res = await real_fn( # type: ignore
2087+
mock_context,
2088+
operation='getSavingsPlansUtilization',
2089+
start_date='2024-01-01',
2090+
end_date='2024-02-01',
2091+
billing_view_arn='',
2092+
)
2093+
2094+
assert res['status'] == 'success'
2095+
mock_impl.assert_awaited_once()
2096+
2097+
2098+
@pytest.mark.asyncio
2099+
async def test_description_names_billing_view_operations():
2100+
"""The description names which operations take billing_view_arn and which refuse it."""
2101+
tool = await cost_explorer_server.get_tool('cost-explorer')
2102+
assert tool is not None and tool.description
2103+
description = ' '.join(tool.description.split())
2104+
assert 'Most Cost Explorer operations support an optional billing_view_arn' not in description
2105+
assert (
2106+
'billing_view_arn is accepted by getCostAndUsage, getCostAndUsageWithResources, '
2107+
'getDimensionValues (COST_AND_USAGE context only), getCostForecast, getUsageForecast, '
2108+
'getTagsOrValues and getCostCategories'
2109+
) in description
2110+
assert (
2111+
'getSavingsPlansUtilization (the AWS API has no billing view parameter) and '
2112+
'getDimensionValues with context RESERVATIONS or SAVINGS_PLANS (AWS does not apply the '
2113+
'view there) do not accept billing_view_arn'
2114+
) in description
2115+
2116+
2117+
@pytest.mark.asyncio
2118+
@pytest.mark.parametrize('context', ['RESERVATIONS', 'SAVINGS_PLANS'])
2119+
async def test_ce_get_dimension_values_rejects_billing_view_arn_outside_cost_and_usage(
2120+
mock_context, sample_billing_view_arn, context
2121+
):
2122+
"""AWS does not apply a billing view to RESERVATIONS or SAVINGS_PLANS dimension values.
2123+
2124+
Sending the ARN would return unscoped values labelled as the caller's view.
2125+
"""
2126+
ce_mod = _reload_ce_with_identity_decorator()
2127+
real_fn = ce_mod.cost_explorer # type: ignore
2128+
2129+
with (
2130+
patch.object(ce_mod, 'create_aws_client') as mock_create_client,
2131+
patch.object(ce_mod, 'get_dimension_values', new_callable=AsyncMock) as mock_impl,
2132+
):
2133+
mock_create_client.return_value = MagicMock()
2134+
2135+
res = await real_fn( # type: ignore
2136+
mock_context,
2137+
operation='getDimensionValues',
2138+
dimension='LINKED_ACCOUNT',
2139+
context=context,
2140+
billing_view_arn=sample_billing_view_arn,
2141+
)
2142+
2143+
assert res['status'] == 'error'
2144+
assert res['error_type'] == 'validation_error'
2145+
assert 'billing_view_arn' in res['message']
2146+
assert f'context={context}' in res['message']
2147+
assert 'Do **NOT** retry with the same billing_view_arn' in res['hint']
2148+
assert context not in res['hint'] # the hint is fixed text; the context is in message
2149+
mock_impl.assert_not_awaited()
2150+
2151+
2152+
@pytest.mark.asyncio
2153+
@pytest.mark.parametrize(
2154+
'context, billing_view_arn',
2155+
[
2156+
('COST_AND_USAGE', 'arn:aws:billing::123456789012:billingview/custom-abc'),
2157+
('SAVINGS_PLANS', None),
2158+
('RESERVATIONS', ''),
2159+
('savings_plans', 'arn:aws:billing::123456789012:billingview/custom-abc'),
2160+
],
2161+
)
2162+
async def test_ce_get_dimension_values_billing_view_arn_allowed_cases(
2163+
mock_context, context, billing_view_arn
2164+
):
2165+
"""COST_AND_USAGE still sends the ARN; the other contexts run when no ARN is set.
2166+
2167+
A lowercase context goes to AWS unchanged, which rejects the context itself.
2168+
"""
2169+
ce_mod = _reload_ce_with_identity_decorator()
2170+
real_fn = ce_mod.cost_explorer # type: ignore
2171+
2172+
with (
2173+
patch.object(ce_mod, 'create_aws_client') as mock_create_client,
2174+
patch.object(ce_mod, 'get_dimension_values', new_callable=AsyncMock) as mock_impl,
2175+
):
2176+
mock_create_client.return_value = MagicMock()
2177+
mock_impl.return_value = {'status': 'success', 'data': {'DimensionValues': []}}
2178+
2179+
res = await real_fn( # type: ignore
2180+
mock_context,
2181+
operation='getDimensionValues',
2182+
dimension='LINKED_ACCOUNT',
2183+
context=context,
2184+
billing_view_arn=billing_view_arn,
2185+
)
2186+
2187+
assert res['status'] == 'success'
2188+
mock_impl.assert_awaited_once()
2189+
await_args = mock_impl.await_args
2190+
assert await_args is not None
2191+
assert await_args.args[-2:] == (billing_view_arn, context)

0 commit comments

Comments
 (0)