Skip to content

Commit b7d641a

Browse files
authored
fix(billing-cost-management): add structured error_type to bcm-pricing-calc, cost-anomaly, and cost-explorer failures (#4716)
* fix(billing-cost-management): classify ClientError failures in bcm-pricing-calc, cost-anomaly, and cost-explorer bcm-pricing-calc: the top-level handler and get_preferences read the AWS message from a non-existent data.error key, always falling back to str(e) and dropping handle_aws_error's classification. A failed preferences check (including AccessDenied) was reported only as PREFERENCES_NOT_CONFIGURED. Error responses now carry top-level error_type, operation, and service; the four duplicated preference short-circuit blocks share _preferences_error_response. cost-anomaly: the bare `raise` for non-validation ClientErrors escaped the tool, since the sibling `except Exception` does not catch exceptions raised from another handler, and a missing AWS message crashed the '2024' check. All ClientErrors now route through handle_aws_error; ValidationException keeps its tailored message and data.error_code. cost-explorer: collapse the identical ClientError/Exception handlers and surface error_type='client_creation_error', operation, and service at the top level on client-creation failure. The legacy data payload is preserved for backward compatibility. * refactor(billing-cost-management): use BCM_PRICING_CALCULATOR_SERVICE_NAME in bcm-pricing-calc top-level error handler * refactor(billing-cost-management): pass error_type/operation/service through format_response Add optional keyword-only error_type, operation, and service to format_response so error paths set the top-level classification directly instead of post-hoc response.update calls. Existing callers are unaffected. * fix(billing-cost-management): classify cost-anomaly validation errors instead of its unreachable ClientError block The ClientError handler in cost_anomaly is effectively unreachable: get_anomalies catches its own exceptions, and create_aws_client makes no network call. Revert it to upstream and instead add top-level error_type='validation_error', operation, and service to the local input validation returns, which are the cost-anomaly failures users actually hit.
1 parent 93991b8 commit b7d641a

9 files changed

Lines changed: 192 additions & 48 deletions

File tree

‎src/billing-cost-management-mcp-server/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343
- Corrected AWS Compute Optimizer ECS and Lambda recommendation response field names that read non-existent SDK fields and returned null. ECS now reads `currentPerformanceRisk`, `autoScalingConfiguration`, and `projectedUtilizationMetrics` (previously the non-existent `currentPerformance`, `autoScalingGroupArn`, and `projectedPerformance`), and Lambda derives the function name from the ARN (there is no `functionName` field).
4444
- Fixed `aws-pricing` tool error responses omitting a structured error type. Each `aws-pricing` operation caught exceptions and returned `format_response('error', ...)` (everything nested under `data`, no top-level `error_type`/`operation`), so the `handle_aws_error` call in the tool wrapper was never reached and failures surfaced without a classifiable type. Operation error paths now route through `handle_aws_error` so responses carry top-level `error_type` and `operation`, and the two non-exception "no results" cases (`get_service_attributes` service-not-found, `get_pricing_from_api` empty price list) carry `error_type='no_results'` plus `operation`. The legacy `data` payload is preserved for backward compatibility.
4545
- Fixed `session-sql` tool error responses omitting a structured error type. `execute_session_sql` caught every failure and returned a bare `{'status': 'error', 'message': ...}` with no `error_type`/`operation`, so failures surfaced without a classifiable type. Error responses now carry top-level `error_type`, `operation` (`session_sql`), and `service` (`SQL`). The `error_type` is the exception class name (e.g. `OperationalError`, `IntegrityError`, `ValueError`) -- a fixed identifier that never contains the query text or bound values, so no query content is exposed. The legacy `status='error'` and `message` payload is preserved for backward compatibility.
46+
- Fixed `bcm-pricing-calc`, `cost-anomaly`, and `cost-explorer` error responses missing a structured error type. `bcm-pricing-calc` read the AWS message from a non-existent `data.error` key and reported every `get_preferences` failure (including `AccessDenied`) only as `PREFERENCES_NOT_CONFIGURED`; error responses now carry top-level `error_type`, `operation`, and `service`. `cost-anomaly` local input validation (bad date format, `start_date` after `end_date`, a future `end_date`, unknown `feedback`/`total_impact_operator`, missing `total_impact_end`) now carries top-level `error_type='validation_error'`, `operation`, and `service`. `cost-explorer` client-creation failures now carry top-level `error_type='client_creation_error'`, `operation`, and `service`. The legacy `data` payload is preserved for backward compatibility.
4647
- Fixed the AWS Compute Optimizer `get_idle_recommendations` operation rejecting `ResourceType` and `Finding` filter values whose casing differs from the idle enums (e.g. `EbsVolume` instead of `EBSVolume`, `idle` instead of `Idle`). Passed values are now normalized case-insensitively against the idle enums read from the installed botocore service model, and the response echoes the normalized filters as `applied_filters`. An `InvalidParameterValueException` now returns a structured error listing `valid_resource_type_values` and `valid_finding_values` so callers can self-correct instead of retrying the same request.
4748

4849
## [0.0.4] - 2025-10-27

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

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,29 @@
4141
)
4242

4343

44+
def _preferences_error_response(preferences_result: Dict[str, Any]) -> Dict[str, Any]:
45+
"""Build the error response for a failed get_preferences check.
46+
47+
Keeps the legacy data payload and adds top-level error_type, operation, and
48+
service, so an AWS failure (e.g. AccessDenied) is not reported only as
49+
PREFERENCES_NOT_CONFIGURED.
50+
51+
Args:
52+
preferences_result: The dict returned by get_preferences on failure.
53+
54+
Returns:
55+
Dict containing the error response
56+
"""
57+
return format_response(
58+
'error',
59+
{'error': preferences_result['error'], 'error_code': 'PREFERENCES_NOT_CONFIGURED'},
60+
preferences_result['error'],
61+
error_type=preferences_result.get('error_type', 'PREFERENCES_NOT_CONFIGURED'),
62+
operation='get_preferences',
63+
service=BCM_PRICING_CALCULATOR_SERVICE_NAME,
64+
)
65+
66+
4467
async def bcm_pricing_calc_core(
4568
ctx: Context,
4669
operation: str,
@@ -139,11 +162,7 @@ async def bcm_pricing_calc_core(
139162
elif operation == 'get_preferences':
140163
preferences_result = await get_preferences(ctx)
141164
if 'error' in preferences_result:
142-
return format_response(
143-
'error',
144-
{'error': preferences_result['error']},
145-
preferences_result['error'],
146-
)
165+
return _preferences_error_response(preferences_result)
147166
else:
148167
return format_response(
149168
'success',
@@ -158,15 +177,19 @@ async def bcm_pricing_calc_core(
158177
except Exception as e:
159178
# Use shared error handler for consistent error handling
160179
error_response = await handle_aws_error(
161-
ctx, e, operation, 'AWS Billing and Cost Management Pricing Calculator'
162-
)
163-
await ctx.error(
164-
f'Failed to process AWS Billing and Cost Management Pricing Calculator request: {error_response.get("data", {}).get("error", str(e))}'
180+
ctx, e, operation, BCM_PRICING_CALCULATOR_SERVICE_NAME
165181
)
182+
# handle_aws_error puts the message and classification at the top level.
183+
error_message = error_response.get('message', str(e))
184+
full_message = f'Failed to process AWS Billing and Cost Management Pricing Calculator request: {error_message}'
185+
await ctx.error(full_message)
166186
return format_response(
167187
'error',
168-
{'error': error_response.get('data', {}).get('error', str(e))},
169-
f'Failed to process AWS Billing and Cost Management Pricing Calculator request: {error_response.get("data", {}).get("error", str(e))}',
188+
{'error': error_message},
189+
full_message,
190+
error_type=error_response.get('error_type', 'unknown_error'),
191+
operation=operation,
192+
service=BCM_PRICING_CALCULATOR_SERVICE_NAME,
170193
)
171194

172195

@@ -331,9 +354,12 @@ async def get_preferences(ctx: Context) -> dict:
331354
error_response = await handle_aws_error(
332355
ctx, e, 'get_preferences', BCM_PRICING_CALCULATOR_SERVICE_NAME
333356
)
334-
error_msg = f'Failed to check BCM Pricing Calculator preferences: {error_response.get("data", {}).get("error", str(e))}'
357+
error_msg = f'Failed to check BCM Pricing Calculator preferences: {error_response.get("message", str(e))}'
335358
await ctx.error(error_msg)
336-
return {'error': error_msg}
359+
return {
360+
'error': error_msg,
361+
'error_type': error_response.get('error_type', 'unknown_error'),
362+
}
337363

338364

339365
async def list_workload_estimates(
@@ -383,13 +409,7 @@ async def list_workload_estimates(
383409
# Check preferences before proceeding
384410
preferences_result = await get_preferences(ctx)
385411
if 'error' in preferences_result:
386-
return format_response(
387-
'error',
388-
{
389-
'error': preferences_result['error'],
390-
'error_code': 'PREFERENCES_NOT_CONFIGURED',
391-
},
392-
)
412+
return _preferences_error_response(preferences_result)
393413

394414
request_params: Dict[str, Any] = {}
395415
# Build request parameters
@@ -553,13 +573,7 @@ async def get_workload_estimate(
553573
# Check preferences before proceeding
554574
preferences_result = await get_preferences(ctx)
555575
if 'error' in preferences_result:
556-
return format_response(
557-
'error',
558-
{
559-
'error': preferences_result['error'],
560-
'error_code': 'PREFERENCES_NOT_CONFIGURED',
561-
},
562-
)
576+
return _preferences_error_response(preferences_result)
563577

564578
# Build request parameters
565579
request_params: Dict[str, Any] = {'identifier': identifier}
@@ -653,13 +667,7 @@ async def list_workload_estimate_usage(
653667
# Check preferences before proceeding
654668
preferences_result = await get_preferences(ctx)
655669
if 'error' in preferences_result:
656-
return format_response(
657-
'error',
658-
{
659-
'error': preferences_result['error'],
660-
'error_code': 'PREFERENCES_NOT_CONFIGURED',
661-
},
662-
)
670+
return _preferences_error_response(preferences_result)
663671

664672
request_params: Dict[str, Any] = {}
665673
# Build request parameters

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,19 @@ async def cost_anomaly(
9292
'error',
9393
{'invalid_parameter': 'start_date'},
9494
f'Invalid start_date format: {start_date}. Date must be in YYYY-MM-DD format.',
95+
error_type='validation_error',
96+
operation='cost_anomaly',
97+
service='Cost Explorer',
9598
)
9699

97100
if not validate_date_format(end_date):
98101
return format_response(
99102
'error',
100103
{'invalid_parameter': 'end_date'},
101104
f'Invalid end_date format: {end_date}. Date must be in YYYY-MM-DD format.',
105+
error_type='validation_error',
106+
operation='cost_anomaly',
107+
service='Cost Explorer',
102108
)
103109

104110
# Parse dates for validation
@@ -115,6 +121,9 @@ async def cost_anomaly(
115121
'error',
116122
{'start_date': start_date, 'end_date': end_date},
117123
'Invalid date range: start_date must be before or equal to end_date.',
124+
error_type='validation_error',
125+
operation='cost_anomaly',
126+
service='Cost Explorer',
118127
)
119128

120129
# Check if dates are in the future
@@ -123,6 +132,9 @@ async def cost_anomaly(
123132
'error',
124133
{'end_date': end_date},
125134
'Invalid end_date: Cannot request anomalies for future dates.',
135+
error_type='validation_error',
136+
operation='cost_anomaly',
137+
service='Cost Explorer',
126138
)
127139

128140
# Check if dates are beyond the 90-day lookback period
@@ -149,6 +161,9 @@ async def cost_anomaly(
149161
'error',
150162
{'invalid_parameter': 'feedback', 'value': feedback},
151163
f'Invalid feedback value: {feedback}. Must be one of: YES, NO, PLANNED_ACTIVITY.',
164+
error_type='validation_error',
165+
operation='cost_anomaly',
166+
service='Cost Explorer',
152167
)
153168

154169
# Validate total impact operator if provided
@@ -165,6 +180,9 @@ async def cost_anomaly(
165180
'error',
166181
{'invalid_parameter': 'total_impact_operator', 'value': total_impact_operator},
167182
f'Invalid total_impact_operator: {total_impact_operator}. Must be one of: {", ".join(valid_operators)}',
183+
error_type='validation_error',
184+
operation='cost_anomaly',
185+
service='Cost Explorer',
168186
)
169187

170188
# Validate total_impact_end is provided when using BETWEEN operator
@@ -173,6 +191,9 @@ async def cost_anomaly(
173191
'error',
174192
{'missing_parameter': 'total_impact_end'},
175193
'When using BETWEEN operator for total_impact, both total_impact_start and total_impact_end must be provided.',
194+
error_type='validation_error',
195+
operation='cost_anomaly',
196+
service='Cost Explorer',
176197
)
177198

178199
await ctx_logger.info(f'Retrieving cost anomalies from {start_date} to {end_date}')
@@ -196,7 +217,12 @@ async def cost_anomaly(
196217
except ValueError as e:
197218
# Handle date parsing errors
198219
return format_response(
199-
'error', {'error_type': 'validation_error'}, f'Date validation error: {str(e)}'
220+
'error',
221+
{'error_type': 'validation_error'},
222+
f'Date validation error: {str(e)}',
223+
error_type='validation_error',
224+
operation='cost_anomaly',
225+
service='Cost Explorer',
200226
)
201227
except ClientError as e:
202228
# Handle AWS service-specific errors

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
get_tags,
2929
get_usage_forecast,
3030
)
31-
from botocore.exceptions import ClientError
3231
from fastmcp import Context, FastMCP
3332
from typing import Any, Dict, Optional
3433

@@ -209,13 +208,18 @@ async def cost_explorer(
209208
ce_client = create_aws_client('ce')
210209
except Exception as client_error:
211210
await ctx.error(f'Failed to create AWS client: {str(client_error)}')
211+
error_message = f'Failed to create AWS client: {str(client_error)}'
212212
return format_response(
213213
'error',
214214
{
215215
'error_type': 'client_creation_error',
216-
'message': f'Failed to create AWS client: {str(client_error)}',
216+
'message': error_message,
217217
'details': repr(client_error),
218218
},
219+
error_message,
220+
error_type='client_creation_error',
221+
operation=operation,
222+
service='Cost Explorer',
219223
)
220224

221225
# Route to the appropriate operation handler
@@ -346,9 +350,6 @@ async def cost_explorer(
346350
else:
347351
return format_response('error', {'message': f'Unknown operation: {operation}'})
348352

349-
except ClientError as e:
350-
# Let the shared handler take care of this
351-
return await handle_aws_error(ctx, e, operation, 'Cost Explorer')
352353
except Exception as e:
353-
# For all other exceptions, use the shared error handler
354+
# The shared handler classifies ClientError and all other exceptions
354355
return await handle_aws_error(ctx, e, operation, 'Cost Explorer')

‎src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,24 @@ async def paginate_aws_response(
381381
return all_results, pagination_metadata
382382

383383

384-
def format_response(status: str, data: Any, message: Optional[str] = None) -> Dict[str, Any]:
384+
def format_response(
385+
status: str,
386+
data: Any,
387+
message: Optional[str] = None,
388+
*,
389+
error_type: Optional[str] = None,
390+
operation: Optional[str] = None,
391+
service: Optional[str] = None,
392+
) -> Dict[str, Any]:
385393
"""Format a standard API response.
386394
387395
Args:
388396
status: Response status ("success" or "error")
389397
data: Response data payload
390398
message: Optional message to include
399+
error_type: Optional error classification, set at the top level like handle_aws_error
400+
operation: Optional name of the operation that produced the response
401+
service: Optional AWS service name
391402
392403
Returns:
393404
Dict containing a standardized response format
@@ -397,4 +408,12 @@ def format_response(status: str, data: Any, message: Optional[str] = None) -> Di
397408
if message:
398409
response['message'] = message
399410

411+
# Only added when supplied, so existing callers keep the same shape
412+
if error_type is not None:
413+
response['error_type'] = error_type
414+
if operation is not None:
415+
response['operation'] = operation
416+
if service is not None:
417+
response['service'] = service
418+
400419
return response

0 commit comments

Comments
 (0)