From 4d41150fb5bb03d6e1ced78945abb0a86c01309a Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Tue, 13 Oct 2026 15:37:44 +0000 Subject: [PATCH 1/4] fix(billing-cost-management-mcp-server): classify botocore ParamValidationError as validation_error ParamValidationError is a client-side botocore failure (unknown kwargs, bad parameter shape, missing required keys, type mismatches) that subclasses BotoCoreError. The previous order in handle_aws_error matched BotoCoreError first, so every ParamValidationError was labeled aws_connection_error, telling callers 'AWS is unreachable' when the real problem was their own request shape. Match ParamValidationError before BotoCoreError and return it as validation_error, preserving boto_error_type=ParamValidationError for diagnostics. Plain BotoCoreError still classifies as aws_connection_error. Add a test asserting the new path is reached and the response carries error_type=validation_error with boto_error_type=ParamValidationError. --- .../utilities/aws_service_base.py | 21 +++++++++++- .../tests/utilities/test_aws_service_base.py | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py index 5cf4b4e56d..53cd91bd90 100644 --- a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py +++ b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py @@ -31,7 +31,7 @@ import re from .logging_utils import get_context_logger, get_logger from botocore.config import Config -from botocore.exceptions import BotoCoreError, ClientError +from botocore.exceptions import BotoCoreError, ClientError, ParamValidationError from datetime import datetime, timedelta from fastmcp import Context from typing import Any, Dict, List, Optional, Tuple, Union @@ -245,6 +245,25 @@ async def handle_aws_error( {'error_type': error_type, 'message': user_message, 'details': str(error)} ) + elif isinstance(error, ParamValidationError): + # ParamValidationError is a client-side botocore validation failure (unknown kwargs, + # bad parameter shape, missing required keys, type mismatches). It subclasses + # BotoCoreError, but it is a validation failure, not a connection failure — surface + # it that way so callers (and skills) see "fix the parameters", not "AWS is + # unreachable". Must be matched BEFORE the BotoCoreError branch below. + error_type = 'validation_error' + user_message = str(error) or 'Invalid parameters' + await ctx_logger.warning(f'Validation error (ParamValidationError): {error_context}') + + error_response.update( + { + 'error_type': error_type, + 'boto_error_type': 'ParamValidationError', + 'message': user_message, + 'details': str(error), + } + ) + elif isinstance(error, BotoCoreError): error_type = 'aws_connection_error' error_code = error.__class__.__name__ diff --git a/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py b/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py index a052aa2435..9b0fa89f75 100644 --- a/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py +++ b/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py @@ -355,6 +355,40 @@ async def test_handle_boto_core_error(self): assert result['operation'] == 'Connect' assert result['message'] == 'AWS service connection error: BotoCoreError' + @pytest.mark.asyncio + async def test_handle_param_validation_error(self): + """Classify ParamValidationError as validation_error, not aws_connection_error. + + ParamValidationError is a client-side botocore validation failure (a BotoCoreError + subclass). It must surface as a parameter-shape problem rather than a transient + connection failure. The `boto_error_type` field is preserved for diagnostics. + """ + # Setup + ctx = AsyncMock() + from botocore.exceptions import ParamValidationError + + error = ParamValidationError( + report='Unknown parameter in filter.Tags: "MatchValue", must be one of: Key, Values, MatchOptions' + ) + + # Execute + result = await handle_aws_error(ctx, error, 'GetCostAndUsage', 'Cost Explorer') + + # Assert — logged as a warning (validation), not an error (connection) + ctx.warning.assert_called_once() + warning_msg = ctx.warning.call_args.args[0] + assert 'ParamValidationError' in warning_msg + assert "Error in Cost Explorer operation 'GetCostAndUsage'" in warning_msg + + # Verify response structure + assert result['status'] == 'error' + assert result['error_type'] == 'validation_error' + assert result['boto_error_type'] == 'ParamValidationError' + assert result['service'] == 'Cost Explorer' + assert result['operation'] == 'GetCostAndUsage' + assert 'Unknown parameter in filter.Tags' in result['message'] + assert 'MatchValue' in result['details'] + class TestFormatResponse: """Tests for format_response function.""" From 15143e9c775d8a0cc42b2c7e017b3d9b5bc6d863 Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Tue, 13 Oct 2026 15:37:53 +0000 Subject: [PATCH 2/4] fix(billing-cost-management-mcp-server): document cost-explorer filter grammar and window limits Agent-side failures on the cost-explorer tool fall into three recurring patterns that the current tool description does not warn against: 1. Filter MatchOptions grammar. Agents guess shape from training memory or carry over Cost Category RULE grammar (CASE_INSENSITIVE, STARTS_WITH, ENDS_WITH, CONTAINS), none of which are valid in a Cost Explorer filter. Add a FILTER EXPRESSION GRAMMAR block listing the per-leaf MatchOptions allow-list (Dimensions: EQUALS, CASE_SENSITIVE; Tags and CostCategories: EQUALS, ABSENT, CASE_SENSITIVE), the explicit NOT-valid list, and the ABSENT-takes-no-Values rule, with correct examples. 2. Window limits. The 14-day cap on getCostAndUsageWithResources, the 38-month cap on standard CE operations, and the 7-month-history + future-start requirement for forecasts were absent or buried inline. Add a WINDOW LIMITS block and tighten the per-operation Notes. 3. getDimensionValues TAG. The DIMENSION REFERENCE listed TAG without qualification, so agents pass it as the dimension argument and hit ValidationException. TAG is valid only as a GroupBy Type and inside filter Tags leaves; clarify this in the DIMENSION REFERENCE and in the getDimensionValues Notes, and point agents to getTagsOrValues for tag discovery. Documentation only, no runtime change. --- .../CHANGELOG.md | 2 + .../tools/cost_explorer_tools.py | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/billing-cost-management-mcp-server/CHANGELOG.md b/src/billing-cost-management-mcp-server/CHANGELOG.md index 08801f73d8..9e51a52aff 100644 --- a/src/billing-cost-management-mcp-server/CHANGELOG.md +++ b/src/billing-cost-management-mcp-server/CHANGELOG.md @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Cost Optimization Hub `get_recommendation` not-found warning now explains the likely causes: the ID may not be a Cost Optimization Hub recommendation ID (for example a resource ID or ARN), or the recommendation may no longer exist because recommendations are refreshed daily and removed once they no longer apply. It points to `list_recommendations` to check the resource's current recommendations. - Cost Optimization Hub `list_recommendations` and `list_recommendation_summaries` now normalize `filters` before calling the API, which is case-sensitive: filter keys match case-, underscore-, and singular/plural-insensitively (`ResourceType` -> `resourceTypes`), enum values match case- and underscore-insensitively (`ec2_instance` -> `Ec2Instance`, `rightsize` -> `Rightsize`), and a single bare value is wrapped in a list. An empty list filter, which the API rejects, now returns a structured `validation_error` naming the filter (and, for enum filters, its accepted values) instead of an `aws_connection_error`. When the API rejects a filter value with `ValidationException`, the response adds `valid_filter_values` for the enum filters sent. The tool description lists all current `resourceTypes` and states these rules. - The AWS Compute Optimizer tool description now states that `account_ids` accepts at most one account ID per call (as a JSON array, e.g. `'["123456789012"]'`), that two or more are rejected so each account needs its own call, that omitting it returns the caller's own account, and that a management account or delegated administrator sets it to target a member account. +- `cost-explorer` tool description now carries a `FILTER EXPRESSION GRAMMAR` block (leaf shape `{: {Key, Values, MatchOptions}}`, the per-leaf `MatchOptions` allow-list — `Dimensions`: `EQUALS`, `CASE_SENSITIVE`; `Tags`/`CostCategories`: `EQUALS`, `ABSENT`, `CASE_SENSITIVE` — and an explicit "NOT valid in a Cost Explorer filter" list for `CASE_INSENSITIVE`/`STARTS_WITH`/`ENDS_WITH`/`CONTAINS` so the Cost Category RULE grammar does not leak into Cost Explorer filters), a `WINDOW LIMITS` block (up to 38 months on standard operations; `getCostAndUsageWithResources` is a hard 14-day limit; forecasts need ≥7 months of history, must start today or later, horizon ≤3 months DAILY / ≤12 months MONTHLY), per-operation notes clarifying that `getDimensionValues` does not accept `TAG` as a `dimension` (use `getTagsOrValues` with `tag_key` instead) and that `getCostForecast` metric must be ALL CAPS, and a DIMENSION REFERENCE note that `TAG` is a GroupBy Type / filter leaf only, not a `getDimensionValues` argument. No behavior change — guidance only. +- `handle_aws_error` now classifies `botocore.exceptions.ParamValidationError` as `validation_error` (preserving `boto_error_type: "ParamValidationError"` for diagnostics) instead of `aws_connection_error`. `ParamValidationError` subclasses `BotoCoreError` and was previously swallowed by that branch, so client-side parameter-shape failures (unknown kwargs, missing required keys, type mismatches) were surfaced to callers as transient connection errors. Matched before the `BotoCoreError` branch so plain `BotoCoreError`s continue to classify as `aws_connection_error`. - AWS Compute Optimizer `filters` are now normalized for every operation, not only `get_idle_recommendations`: filter keys (`Name`/`Values`), names (case- and singular/plural-insensitive), and values (case- and underscore-insensitive, so a returned `OVER_PROVISIONED` can be passed back as `Overprovisioned`) are folded onto the spelling the API expects, against per-operation tables of accepted values (read from the botocore service model where available). Tag filters are left as given, unrecognized names and values pass through unchanged, and the response echoes the sent filters as `applied_filters`. A bare `Finding` on `get_rds_recommendations` is rejected as ambiguous (use `InstanceFinding` or `StorageFinding`). An `InvalidParameterValueException` now returns a structured error with top-level `error_type`, `operation`, `service`, `request_id`, and `http_status`, and lists `valid_filters` and `submitted_filters` when the service reports a filter problem. - The AWS Compute Optimizer tool now rejects locally, with a structured `validation_error`, a `max_results` above the API limit (1000, or 100 for `get_idle_recommendations`) and more than one entry in `account_ids` (the API accepts one account ID per request), instead of forwarding them to the API. The tool description now lists the accepted filter names and values per operation, and describes `get_rds_recommendations` as covering RDS and Aurora databases. - Renamed the Cost Optimization Hub `list_recommendation_summaries` response field `estimated_total_savings` to `estimated_total_deduped_savings`, matching the API's `estimatedTotalDedupedSavings` and making clear the total is de-duplicated. diff --git a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py index defc078c2d..b3b64ffa95 100644 --- a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py +++ b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py @@ -72,6 +72,44 @@ - **Resource-level cost analysis** (last 14 days) - **Multi-dimensional cost analysis** with complex grouping +WINDOW LIMITS (per operation — exceeding returns DataUnavailableException): +- getCostAndUsage, getDimensionValues, getTagsOrValues, getCostCategories, + getSavingsPlansUtilization: up to 38 months of history (standard is the last 13 months; + the full 38 months is available only when multi-year monthly data is enabled on the account). +- getCostAndUsageWithResources: last 14 days ONLY — a hard limit. Older ranges return + DataUnavailableException with no fallback. +- getCostForecast, getUsageForecast: start_date must be TODAY or later (never in the past), + and the account needs at least 7 months of usage history for the forecast to generate. + Maximum horizon: 3 months at DAILY granularity, 12 months at MONTHLY. + +FILTER EXPRESSION GRAMMAR (the `filter` argument is a JSON STRING): +Top-level object has exactly ONE of these keys: +- Leaf keys: `Dimensions`, `Tags`, `CostCategories` +- Logical keys: `And`, `Or`, `Not` (take arrays/objects of other filter expressions) + +Leaf shape: +`{"": {"Key": "...", "Values": [...], "MatchOptions": [...]}}` + +Valid MatchOptions ALLOW-LIST per leaf type (anything else returns +`ValidationException: MatchOptions contain not allowed match option`): +- Dimensions: EQUALS, CASE_SENSITIVE +- Tags: EQUALS, ABSENT, CASE_SENSITIVE +- CostCategories: EQUALS, ABSENT, CASE_SENSITIVE + +NOT valid in a Cost Explorer filter: `CASE_INSENSITIVE`, `STARTS_WITH`, `ENDS_WITH`, +`CONTAINS`. Those options belong to Cost Category RULE definitions (a different grammar used +by CreateCostCategoryDefinition) — do not reuse them here. + +ABSENT means "resources that are missing this tag or cost-category entirely" and takes NO +`Values` field. Including an empty `Values: []` alongside `ABSENT` returns +`ValidationException: Expression must not have values set when ABSENT is provided`. + +Correct filter examples (remember: the `filter` argument is itself JSON STRING-ENCODED): +- `{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Relational Database Service"]}}` +- `{"Tags": {"Key": "env", "Values": ["prod"]}}` +- `{"Tags": {"Key": "team", "MatchOptions": ["ABSENT"]}}` +- `{"And": [{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Elastic Compute Cloud - Compute"]}}, {"Dimensions": {"Key": "REGION", "Values": ["us-east-1"]}}]}` + ## OPERATIONS 1) getCostAndUsage — account-level historical cost/usage @@ -82,7 +120,7 @@ 2. getCostAndUsageWithResources - Resource-level cost data (limited to last 14 days) Required: operation="getCostAndUsageWithResources", filter, granularity, start_date, end_date Optional: metrics, group_by, next_token, max_pages, billing_view_arn - Notes: RESOURCE_ID must be included in either filter OR group_by parameters. This operation is limited to past 14 days of data from current date. Hourly granularity is only available for EC2-Instances resource-level data. All other resource-level data is available at daily granularity. + Notes: RESOURCE_ID must be included in either filter OR group_by parameters. **This operation is limited to the past 14 days of data from the current date — this is a hard limit; older ranges return DataUnavailableException with no fallback.** Hourly granularity is only available for EC2-Instances resource-level data. All other resource-level data is available at daily granularity. Example: {"operation": "getCostAndUsageWithResources", "start_date": "2025-08-07", "end_date": "2025-08-21", "granularity": "DAILY", "filter": "{\"Dimensions\": {\"Key\": \"SERVICE\", \"Values\": [\"Amazon Elastic Compute Cloud - Compute\"]}}", "group_by": "[{\"Type\": \"DIMENSION\", \"Key\": \"RESOURCE_ID\"}]"} Returns: Cost data with resource-level granularity @@ -97,7 +135,7 @@ Required: operation="getCostForecast", metric, granularity, start_date, end_date Optional: filter, prediction_interval_level, billing_view_arn Example: {"operation": "getCostForecast", "metric": "UNBLENDED_COST", "granularity": "MONTHLY", "start_date": "2025-08-22", "end_date": "2025-11-22"} - Notes: metric value for this operation should be in all caps + Notes: `metric` must be ALL CAPS and one of UNBLENDED_COST, BLENDED_COST, AMORTIZED_COST, NET_UNBLENDED_COST, NET_AMORTIZED_COST, USAGE_QUANTITY, NORMALIZED_USAGE_AMOUNT. `start_date` must be today or later. The account needs at least 7 months of usage history for a forecast to generate — otherwise DataUnavailableException. Horizon is up to 3 months (DAILY) or 12 months (MONTHLY). Returns: Cost forecast for specified time period and granularity 5. getUsageForecast - Future usage projections @@ -127,7 +165,7 @@ Required: operation="getSavingsPlansUtilization", start_date, end_date Optional: granularity, filter Example: {"operation": "getSavingsPlansUtilization", "granularity": "MONTHLY"} - Notes: This operation supports only DAILY and MONTHLY granularity + Notes: Supports only DAILY and MONTHLY granularity. Data is available for up to the last 38 months (standard 13 months unless multi-year monthly is enabled); older windows return DataUnavailableException. DataUnavailableException on a current-day window usually means no active Savings Plans in the period — report "no Savings Plans data for this window" rather than retrying with a different window. Returns: Savings Plans utilization for the specified time period DIMENSION REFERENCE: @@ -147,7 +185,7 @@ - PURCHASE_TYPE: Reservation type (e.g., On-Demand, Reserved) - REGION: AWS Region - SERVICE: AWS service (e.g., Amazon DynamoDB) -- TAG: Cost allocation tag — only valid as a getDimensionValues dimension in the RESERVATIONS coverage context. NOT a COST_AND_USAGE dimension; to list a tag key's values use operation=getTagsOrValues (with tag_key), and to filter cost/usage by tag use the filter Tags field. +- TAG: Cost allocation tag — valid as a GroupBy Type (`[{"Type":"TAG","Key":""}]`) and inside filter `Tags` leaves. As a getDimensionValues dimension it is only valid in the RESERVATIONS coverage context, NOT in COST_AND_USAGE; to list a tag key's values use operation=getTagsOrValues (with tag_key). - TENANCY: EC2 tenancy (shared, dedicated) - USAGE_TYPE: Usage type (e.g., DataTransfer-In-Bytes) - RECORD_TYPE: Charge types (e.g., RI fees, usage costs) From cf25aa691c29d9dfc52320d65823fb7c477663d8 Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Tue, 13 Oct 2026 16:17:43 +0000 Subject: [PATCH 3/4] fix(billing-cost-management-mcp-server): correct cost-explorer limits against the API reference Corrects statements added in the previous commit so that every limit in the cost-explorer tool description matches the Cost Explorer API reference and user guide: - Forecast horizon is 3 months at DAILY and 18 months at MONTHLY (was 12). - Remove the claim that forecasts need 7 months of history. The user guide says only that no forecast is produced without enough history, commonly for accounts with less than one full billing cycle. - getCostForecast accepts only the five cost metrics; USAGE_QUANTITY and NORMALIZED_USAGE_AMOUNT belong to getUsageForecast. - getSavingsPlansUtilization: start must be within the last 13 months and end before the current date (was "up to 38 months"). - getCostAndUsage: current month plus the previous 13 months; older months only at MONTHLY with multi-year data enabled. Drop the history limits stated for getDimensionValues, getTagsOrValues and getCostCategories, which the reference does not give. - Scope the MatchOptions list to getCostAndUsage and getCostAndUsageWithResources, the operations whose reference documents it, and note the default (EQUALS, CASE_SENSITIVE). - Remove error names and error messages the reference does not document, and the unsupported reading of DataUnavailableException for Savings Plans utilization. Also move the ParamValidationError entry in CHANGELOG.md from Changed to Fixed. Documentation only. --- .../CHANGELOG.md | 4 +- .../tools/cost_explorer_tools.py | 48 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/billing-cost-management-mcp-server/CHANGELOG.md b/src/billing-cost-management-mcp-server/CHANGELOG.md index 9e51a52aff..547b2b21cf 100644 --- a/src/billing-cost-management-mcp-server/CHANGELOG.md +++ b/src/billing-cost-management-mcp-server/CHANGELOG.md @@ -41,8 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Cost Optimization Hub `get_recommendation` not-found warning now explains the likely causes: the ID may not be a Cost Optimization Hub recommendation ID (for example a resource ID or ARN), or the recommendation may no longer exist because recommendations are refreshed daily and removed once they no longer apply. It points to `list_recommendations` to check the resource's current recommendations. - Cost Optimization Hub `list_recommendations` and `list_recommendation_summaries` now normalize `filters` before calling the API, which is case-sensitive: filter keys match case-, underscore-, and singular/plural-insensitively (`ResourceType` -> `resourceTypes`), enum values match case- and underscore-insensitively (`ec2_instance` -> `Ec2Instance`, `rightsize` -> `Rightsize`), and a single bare value is wrapped in a list. An empty list filter, which the API rejects, now returns a structured `validation_error` naming the filter (and, for enum filters, its accepted values) instead of an `aws_connection_error`. When the API rejects a filter value with `ValidationException`, the response adds `valid_filter_values` for the enum filters sent. The tool description lists all current `resourceTypes` and states these rules. - The AWS Compute Optimizer tool description now states that `account_ids` accepts at most one account ID per call (as a JSON array, e.g. `'["123456789012"]'`), that two or more are rejected so each account needs its own call, that omitting it returns the caller's own account, and that a management account or delegated administrator sets it to target a member account. -- `cost-explorer` tool description now carries a `FILTER EXPRESSION GRAMMAR` block (leaf shape `{: {Key, Values, MatchOptions}}`, the per-leaf `MatchOptions` allow-list — `Dimensions`: `EQUALS`, `CASE_SENSITIVE`; `Tags`/`CostCategories`: `EQUALS`, `ABSENT`, `CASE_SENSITIVE` — and an explicit "NOT valid in a Cost Explorer filter" list for `CASE_INSENSITIVE`/`STARTS_WITH`/`ENDS_WITH`/`CONTAINS` so the Cost Category RULE grammar does not leak into Cost Explorer filters), a `WINDOW LIMITS` block (up to 38 months on standard operations; `getCostAndUsageWithResources` is a hard 14-day limit; forecasts need ≥7 months of history, must start today or later, horizon ≤3 months DAILY / ≤12 months MONTHLY), per-operation notes clarifying that `getDimensionValues` does not accept `TAG` as a `dimension` (use `getTagsOrValues` with `tag_key` instead) and that `getCostForecast` metric must be ALL CAPS, and a DIMENSION REFERENCE note that `TAG` is a GroupBy Type / filter leaf only, not a `getDimensionValues` argument. No behavior change — guidance only. -- `handle_aws_error` now classifies `botocore.exceptions.ParamValidationError` as `validation_error` (preserving `boto_error_type: "ParamValidationError"` for diagnostics) instead of `aws_connection_error`. `ParamValidationError` subclasses `BotoCoreError` and was previously swallowed by that branch, so client-side parameter-shape failures (unknown kwargs, missing required keys, type mismatches) were surfaced to callers as transient connection errors. Matched before the `BotoCoreError` branch so plain `BotoCoreError`s continue to classify as `aws_connection_error`. +- The `cost-explorer` tool description now documents the filter expression grammar: the leaf shape `{: {Key, Values, MatchOptions}}`, the `MatchOptions` that `getCostAndUsage` and `getCostAndUsageWithResources` accept (`Dimensions`: `EQUALS`, `CASE_SENSITIVE`; `Tags` and `CostCategories`: `EQUALS`, `ABSENT`, `CASE_SENSITIVE`), and that the other `MatchOption` values belong to Cost Category rules and anomaly subscriptions. It also documents the time-period limits of `getCostAndUsage`, `getCostAndUsageWithResources`, `getSavingsPlansUtilization`, and the forecast operations, the metrics `getCostForecast` accepts, and that `TAG` is not a valid `dimension` for `getDimensionValues` (use `getTagsOrValues`). Documentation only. - AWS Compute Optimizer `filters` are now normalized for every operation, not only `get_idle_recommendations`: filter keys (`Name`/`Values`), names (case- and singular/plural-insensitive), and values (case- and underscore-insensitive, so a returned `OVER_PROVISIONED` can be passed back as `Overprovisioned`) are folded onto the spelling the API expects, against per-operation tables of accepted values (read from the botocore service model where available). Tag filters are left as given, unrecognized names and values pass through unchanged, and the response echoes the sent filters as `applied_filters`. A bare `Finding` on `get_rds_recommendations` is rejected as ambiguous (use `InstanceFinding` or `StorageFinding`). An `InvalidParameterValueException` now returns a structured error with top-level `error_type`, `operation`, `service`, `request_id`, and `http_status`, and lists `valid_filters` and `submitted_filters` when the service reports a filter problem. - The AWS Compute Optimizer tool now rejects locally, with a structured `validation_error`, a `max_results` above the API limit (1000, or 100 for `get_idle_recommendations`) and more than one entry in `account_ids` (the API accepts one account ID per request), instead of forwarding them to the API. The tool description now lists the accepted filter names and values per operation, and describes `get_rds_recommendations` as covering RDS and Aurora databases. - Renamed the Cost Optimization Hub `list_recommendation_summaries` response field `estimated_total_savings` to `estimated_total_deduped_savings`, matching the API's `estimatedTotalDedupedSavings` and making clear the total is de-duplicated. @@ -64,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mapped `NotFoundException`, `ExpiredNextTokenException`, `InvalidRequestException`, `InternalErrorException` and `not_found`, used by Budgets, Pricing, Athena and `enterprise-support`. - An AWS error with an empty message now gets the default message instead of an empty string. - Error responses from `aws-pricing`, `bcm-pricing-calc`, `cost-allocation-tags`, `cost-category`, `cost-explorer`, `credits`, `enterprise-support`, `invoice-units`, `invoicing`, and `procurement-preferences` validation paths now carry the documented top-level `message`. They previously put the text only in `data.message` (or `data.error` for `bcm-pricing-calc`), which is still present. +- Fixed `handle_aws_error` reporting a botocore `ParamValidationError` as `aws_connection_error`. The error is raised before any request is sent when parameters are malformed (for example an unknown filter key), but because it subclasses `BotoCoreError` it was reported as a connection failure. It now returns `validation_error` with botocore's message naming the invalid parameter, and keeps `boto_error_type: ParamValidationError`. Other `BotoCoreError` subclasses still return `aws_connection_error`. - Fixed the `rec-details` tool returning an error instead of AWS Compute Optimizer details for RDS recommendations. It called `get_rds_instance_recommendations`, which does not exist in boto3; it now calls `get_rds_database_recommendations` with `resourceArns`. - Fixed the `compute-optimizer-automation` tool rejecting `start_time`/`end_time` values that end in the ISO 8601 UTC designator `Z` (e.g. `2026-07-10T00:00:00Z`). A single trailing `Z` is now accepted; the values were already interpreted as UTC. - Fixed timestamp formatting in the `rec-details` tool and Cost Optimization Hub responses. `rec-details` divided boto3 `datetime` values by 1000, so every `last_refresh_timestamp` became an error string; Cost Optimization Hub treated every number as epoch milliseconds (placing epoch seconds in January 1970) and rendered it in local time. Both now use a shared formatter that accepts `datetime`, epoch seconds, and epoch milliseconds and returns a UTC ISO 8601 string, or `null` (with a logged warning) instead of an error string when a value cannot be converted. diff --git a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py index b3b64ffa95..eb0b0cd295 100644 --- a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py +++ b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/cost_explorer_tools.py @@ -72,37 +72,41 @@ - **Resource-level cost analysis** (last 14 days) - **Multi-dimensional cost analysis** with complex grouping -WINDOW LIMITS (per operation — exceeding returns DataUnavailableException): -- getCostAndUsage, getDimensionValues, getTagsOrValues, getCostCategories, - getSavingsPlansUtilization: up to 38 months of history (standard is the last 13 months; - the full 38 months is available only when multi-year monthly data is enabled on the account). -- getCostAndUsageWithResources: last 14 days ONLY — a hard limit. Older ranges return - DataUnavailableException with no fallback. -- getCostForecast, getUsageForecast: start_date must be TODAY or later (never in the past), - and the account needs at least 7 months of usage history for the forecast to generate. - Maximum horizon: 3 months at DAILY granularity, 12 months at MONTHLY. +WINDOW LIMITS: +- getCostAndUsage: the current month and the previous 13 months at DAILY and MONTHLY + granularity. Older months are available at MONTHLY granularity only if multi-year data is + enabled in Cost Explorer settings. HOURLY data covers only the previous 14 days and must also + be enabled there. +- getCostAndUsageWithResources: the range must be within the last 14 days (start_date no + earlier than 14 days ago). +- getSavingsPlansUtilization: start_date must be within the last 13 months, and end_date must + be after start_date and before the current date. +- getCostForecast, getUsageForecast: start_date must be today or later. The maximum horizon is + 3 months at DAILY granularity and 18 months at MONTHLY. Cost Explorer does not produce a + forecast when the account has too little history, which is common for accounts with less + than one full billing cycle. FILTER EXPRESSION GRAMMAR (the `filter` argument is a JSON STRING): -Top-level object has exactly ONE of these keys: +The top-level object has exactly ONE of these keys: - Leaf keys: `Dimensions`, `Tags`, `CostCategories` -- Logical keys: `And`, `Or`, `Not` (take arrays/objects of other filter expressions) +- Logical keys: `And` and `Or` (each takes an array of expressions), `Not` (takes one + expression) Leaf shape: `{"": {"Key": "...", "Values": [...], "MatchOptions": [...]}}` -Valid MatchOptions ALLOW-LIST per leaf type (anything else returns -`ValidationException: MatchOptions contain not allowed match option`): +MatchOptions accepted by getCostAndUsage and getCostAndUsageWithResources: - Dimensions: EQUALS, CASE_SENSITIVE - Tags: EQUALS, ABSENT, CASE_SENSITIVE - CostCategories: EQUALS, ABSENT, CASE_SENSITIVE +The default is EQUALS and CASE_SENSITIVE, so MatchOptions can usually be omitted. Not every +operation supports MatchOptions. -NOT valid in a Cost Explorer filter: `CASE_INSENSITIVE`, `STARTS_WITH`, `ENDS_WITH`, -`CONTAINS`. Those options belong to Cost Category RULE definitions (a different grammar used -by CreateCostCategoryDefinition) — do not reuse them here. +The MatchOption enum also contains `CASE_INSENSITIVE`, `STARTS_WITH`, `ENDS_WITH`, `CONTAINS`, +and `GREATER_THAN_OR_EQUAL`. These apply to Cost Category rules and anomaly subscriptions, not +to these filters. -ABSENT means "resources that are missing this tag or cost-category entirely" and takes NO -`Values` field. Including an empty `Values: []` alongside `ABSENT` returns -`ValidationException: Expression must not have values set when ABSENT is provided`. +For Tags, `ABSENT` with a `Key` and no `Values` matches costs that don't have that tag key. Correct filter examples (remember: the `filter` argument is itself JSON STRING-ENCODED): - `{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Relational Database Service"]}}` @@ -120,7 +124,7 @@ 2. getCostAndUsageWithResources - Resource-level cost data (limited to last 14 days) Required: operation="getCostAndUsageWithResources", filter, granularity, start_date, end_date Optional: metrics, group_by, next_token, max_pages, billing_view_arn - Notes: RESOURCE_ID must be included in either filter OR group_by parameters. **This operation is limited to the past 14 days of data from the current date — this is a hard limit; older ranges return DataUnavailableException with no fallback.** Hourly granularity is only available for EC2-Instances resource-level data. All other resource-level data is available at daily granularity. + Notes: RESOURCE_ID must be included in either filter OR group_by parameters. The range must be within the last 14 days (start_date no earlier than 14 days ago). Hourly granularity is only available for EC2-Instances resource-level data. All other resource-level data is available at daily granularity. Example: {"operation": "getCostAndUsageWithResources", "start_date": "2025-08-07", "end_date": "2025-08-21", "granularity": "DAILY", "filter": "{\"Dimensions\": {\"Key\": \"SERVICE\", \"Values\": [\"Amazon Elastic Compute Cloud - Compute\"]}}", "group_by": "[{\"Type\": \"DIMENSION\", \"Key\": \"RESOURCE_ID\"}]"} Returns: Cost data with resource-level granularity @@ -135,7 +139,7 @@ Required: operation="getCostForecast", metric, granularity, start_date, end_date Optional: filter, prediction_interval_level, billing_view_arn Example: {"operation": "getCostForecast", "metric": "UNBLENDED_COST", "granularity": "MONTHLY", "start_date": "2025-08-22", "end_date": "2025-11-22"} - Notes: `metric` must be ALL CAPS and one of UNBLENDED_COST, BLENDED_COST, AMORTIZED_COST, NET_UNBLENDED_COST, NET_AMORTIZED_COST, USAGE_QUANTITY, NORMALIZED_USAGE_AMOUNT. `start_date` must be today or later. The account needs at least 7 months of usage history for a forecast to generate — otherwise DataUnavailableException. Horizon is up to 3 months (DAILY) or 12 months (MONTHLY). + Notes: `metric` must be ALL CAPS and one of UNBLENDED_COST, BLENDED_COST, AMORTIZED_COST, NET_UNBLENDED_COST, NET_AMORTIZED_COST. `start_date` must be today or later. The horizon is up to 3 months (DAILY) or 18 months (MONTHLY). Returns: Cost forecast for specified time period and granularity 5. getUsageForecast - Future usage projections @@ -165,7 +169,7 @@ Required: operation="getSavingsPlansUtilization", start_date, end_date Optional: granularity, filter Example: {"operation": "getSavingsPlansUtilization", "granularity": "MONTHLY"} - Notes: Supports only DAILY and MONTHLY granularity. Data is available for up to the last 38 months (standard 13 months unless multi-year monthly is enabled); older windows return DataUnavailableException. DataUnavailableException on a current-day window usually means no active Savings Plans in the period — report "no Savings Plans data for this window" rather than retrying with a different window. + Notes: Supports only DAILY and MONTHLY granularity. start_date must be within the last 13 months, and end_date must be before the current date. Returns: Savings Plans utilization for the specified time period DIMENSION REFERENCE: From a5bee5d6594921d449ee3dce054fe4a99dc27e8a Mon Sep 17 00:00:00 2001 From: Chris Allen Date: Fri, 16 Oct 2026 14:59:04 +0000 Subject: [PATCH 4/4] fix(billing-cost-management-mcp-server): stop retrying credential and configuration errors NoCredentialsError, PartialCredentialsError, NoRegionError, ProfileNotFound and UnknownServiceError subclass BotoCoreError, so handle_aws_error reported them as aws_connection_error and retry_strategy resolved to retry_same_request. Retrying cannot fix them during the run. Keep error_type=aws_connection_error so the classification stays stable, and set retry_strategy=do_not_retry plus a hint explicitly on the response; ErrorSignalingMiddleware keeps a retry_strategy that is already set. The real connection failures (EndpointConnectionError, ConnectTimeoutError, ReadTimeoutError, ConnectionClosedError) are still retry_same_request. Also assert that ParamValidationError resolves to modify_request, and document both in the README error-response section and CHANGELOG. Requested in review by @finklen-amazon. --- .../CHANGELOG.md | 5 +- .../README.md | 2 +- .../utilities/aws_service_base.py | 36 +++++++- .../tests/utilities/test_aws_service_base.py | 82 +++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/billing-cost-management-mcp-server/CHANGELOG.md b/src/billing-cost-management-mcp-server/CHANGELOG.md index 547b2b21cf..77135255f6 100644 --- a/src/billing-cost-management-mcp-server/CHANGELOG.md +++ b/src/billing-cost-management-mcp-server/CHANGELOG.md @@ -41,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Cost Optimization Hub `get_recommendation` not-found warning now explains the likely causes: the ID may not be a Cost Optimization Hub recommendation ID (for example a resource ID or ARN), or the recommendation may no longer exist because recommendations are refreshed daily and removed once they no longer apply. It points to `list_recommendations` to check the resource's current recommendations. - Cost Optimization Hub `list_recommendations` and `list_recommendation_summaries` now normalize `filters` before calling the API, which is case-sensitive: filter keys match case-, underscore-, and singular/plural-insensitively (`ResourceType` -> `resourceTypes`), enum values match case- and underscore-insensitively (`ec2_instance` -> `Ec2Instance`, `rightsize` -> `Rightsize`), and a single bare value is wrapped in a list. An empty list filter, which the API rejects, now returns a structured `validation_error` naming the filter (and, for enum filters, its accepted values) instead of an `aws_connection_error`. When the API rejects a filter value with `ValidationException`, the response adds `valid_filter_values` for the enum filters sent. The tool description lists all current `resourceTypes` and states these rules. - The AWS Compute Optimizer tool description now states that `account_ids` accepts at most one account ID per call (as a JSON array, e.g. `'["123456789012"]'`), that two or more are rejected so each account needs its own call, that omitting it returns the caller's own account, and that a management account or delegated administrator sets it to target a member account. -- The `cost-explorer` tool description now documents the filter expression grammar: the leaf shape `{: {Key, Values, MatchOptions}}`, the `MatchOptions` that `getCostAndUsage` and `getCostAndUsageWithResources` accept (`Dimensions`: `EQUALS`, `CASE_SENSITIVE`; `Tags` and `CostCategories`: `EQUALS`, `ABSENT`, `CASE_SENSITIVE`), and that the other `MatchOption` values belong to Cost Category rules and anomaly subscriptions. It also documents the time-period limits of `getCostAndUsage`, `getCostAndUsageWithResources`, `getSavingsPlansUtilization`, and the forecast operations, the metrics `getCostForecast` accepts, and that `TAG` is not a valid `dimension` for `getDimensionValues` (use `getTagsOrValues`). Documentation only. +- The `cost-explorer` tool description now documents the filter expression grammar: the leaf shape `{: {Key, Values, MatchOptions}}`, the `MatchOptions` that `getCostAndUsage` and `getCostAndUsageWithResources` accept (`Dimensions`: `EQUALS`, `CASE_SENSITIVE`; `Tags` and `CostCategories`: `EQUALS`, `ABSENT`, `CASE_SENSITIVE`), and that the other `MatchOption` values belong to Cost Category rules and anomaly subscriptions. It also documents the time-period limits of `getCostAndUsage`, `getCostAndUsageWithResources`, `getSavingsPlansUtilization`, and the forecast operations, the metrics `getCostForecast` accepts, and that `TAG` can be used as a `GroupBy` type and in `Tags` filter leaves. Documentation only. - AWS Compute Optimizer `filters` are now normalized for every operation, not only `get_idle_recommendations`: filter keys (`Name`/`Values`), names (case- and singular/plural-insensitive), and values (case- and underscore-insensitive, so a returned `OVER_PROVISIONED` can be passed back as `Overprovisioned`) are folded onto the spelling the API expects, against per-operation tables of accepted values (read from the botocore service model where available). Tag filters are left as given, unrecognized names and values pass through unchanged, and the response echoes the sent filters as `applied_filters`. A bare `Finding` on `get_rds_recommendations` is rejected as ambiguous (use `InstanceFinding` or `StorageFinding`). An `InvalidParameterValueException` now returns a structured error with top-level `error_type`, `operation`, `service`, `request_id`, and `http_status`, and lists `valid_filters` and `submitted_filters` when the service reports a filter problem. - The AWS Compute Optimizer tool now rejects locally, with a structured `validation_error`, a `max_results` above the API limit (1000, or 100 for `get_idle_recommendations`) and more than one entry in `account_ids` (the API accepts one account ID per request), instead of forwarding them to the API. The tool description now lists the accepted filter names and values per operation, and describes `get_rds_recommendations` as covering RDS and Aurora databases. - Renamed the Cost Optimization Hub `list_recommendation_summaries` response field `estimated_total_savings` to `estimated_total_deduped_savings`, matching the API's `estimatedTotalDedupedSavings` and making clear the total is de-duplicated. @@ -63,7 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mapped `NotFoundException`, `ExpiredNextTokenException`, `InvalidRequestException`, `InternalErrorException` and `not_found`, used by Budgets, Pricing, Athena and `enterprise-support`. - An AWS error with an empty message now gets the default message instead of an empty string. - Error responses from `aws-pricing`, `bcm-pricing-calc`, `cost-allocation-tags`, `cost-category`, `cost-explorer`, `credits`, `enterprise-support`, `invoice-units`, `invoicing`, and `procurement-preferences` validation paths now carry the documented top-level `message`. They previously put the text only in `data.message` (or `data.error` for `bcm-pricing-calc`), which is still present. -- Fixed `handle_aws_error` reporting a botocore `ParamValidationError` as `aws_connection_error`. The error is raised before any request is sent when parameters are malformed (for example an unknown filter key), but because it subclasses `BotoCoreError` it was reported as a connection failure. It now returns `validation_error` with botocore's message naming the invalid parameter, and keeps `boto_error_type: ParamValidationError`. Other `BotoCoreError` subclasses still return `aws_connection_error`. +- Fixed `handle_aws_error` reporting a botocore `ParamValidationError` as `aws_connection_error`. The error is raised before any request is sent when parameters are malformed (for example an unknown filter key), but because it subclasses `BotoCoreError` it was reported as a connection failure. It now returns `validation_error` (so `retry_strategy` is `modify_request` instead of `retry_same_request`) with botocore's message naming the invalid parameter, and keeps `boto_error_type: ParamValidationError`. +- Fixed credential and configuration errors being reported as retryable. `NoCredentialsError`, `PartialCredentialsError`, `NoRegionError`, `ProfileNotFound`, and `UnknownServiceError` subclass `BotoCoreError` and were returned with `retry_strategy: retry_same_request`, although retrying cannot fix them during the run. They keep `error_type: aws_connection_error` but now set `retry_strategy: do_not_retry` and a `hint` to fix the AWS profile, credentials, or region. Connection failures (`EndpointConnectionError`, `ConnectTimeoutError`, `ReadTimeoutError`, `ConnectionClosedError`) are still `retry_same_request`. - Fixed the `rec-details` tool returning an error instead of AWS Compute Optimizer details for RDS recommendations. It called `get_rds_instance_recommendations`, which does not exist in boto3; it now calls `get_rds_database_recommendations` with `resourceArns`. - Fixed the `compute-optimizer-automation` tool rejecting `start_time`/`end_time` values that end in the ISO 8601 UTC designator `Z` (e.g. `2026-07-10T00:00:00Z`). A single trailing `Z` is now accepted; the values were already interpreted as UTC. - Fixed timestamp formatting in the `rec-details` tool and Cost Optimization Hub responses. `rec-details` divided boto3 `datetime` values by 1000, so every `last_refresh_timestamp` became an error string; Cost Optimization Hub treated every number as epoch milliseconds (placing epoch seconds in January 1970) and rendered it in local time. Both now use a shared formatter that accepts `datetime`, epoch seconds, and epoch milliseconds and returns a UTC ISO 8601 string, or `null` (with a logged warning) instead of an error string when a value cannot be converted. diff --git a/src/billing-cost-management-mcp-server/README.md b/src/billing-cost-management-mcp-server/README.md index f99507e04f..45efe729aa 100644 --- a/src/billing-cost-management-mcp-server/README.md +++ b/src/billing-cost-management-mcp-server/README.md @@ -403,7 +403,7 @@ Tool errors are returned with `isError: true` and a JSON body that includes: | `modify_request` | `validation_error`, `invalid_operation`, `invalid_parameter_value`, `ValidationException`, `InvalidParameter*`, `InvalidInputException`, `InvalidNextTokenException`, `ExpiredNextTokenException`, `InvalidRequestException`, `BadRequestException`, `BillExpirationException`, `RequestChangedException`, `no_results`, `ResourceNotFoundException`, `resource_not_found`, `NotFoundException`, `not_found` | | `do_not_retry` | `AccessDenied*`, `access_denied`, `UnauthorizedOperation`, `UnauthorizedException`, `AuthFailure`, `OptInRequiredException`, `opt_in_required`, `enrollment_error`, `DataUnavailableException`, and any missing or unrecognized `error_type` | -A tool can override the mapping for a specific error by passing `retry_strategy=` and `hint=` to `format_response`. Session SQL errors from invalid queries are `modify_request`, and when every region of a multi-region call fails, `retry_strategy` is combined from each region's own error. The full mapping is in `utilities/error_contract.py`. +A tool can override the mapping for a specific error by passing `retry_strategy=` and `hint=` to `format_response`. Session SQL errors from invalid queries are `modify_request`, and when every region of a multi-region call fails, `retry_strategy` is combined from each region's own error. A botocore `ParamValidationError` (malformed request parameters, rejected before any request is sent) is a `validation_error`. Credential and configuration errors (`NoCredentialsError`, `PartialCredentialsError`, `NoRegionError`, `ProfileNotFound`, `UnknownServiceError`) keep `error_type: aws_connection_error` but are `do_not_retry`. The full mapping is in `utilities/error_contract.py`. ## Supported AWS Services diff --git a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py index 53cd91bd90..9bba79ff5b 100644 --- a/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py +++ b/src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/utilities/aws_service_base.py @@ -29,9 +29,19 @@ import json import os import re +from .error_contract import DO_NOT_RETRY from .logging_utils import get_context_logger, get_logger from botocore.config import Config -from botocore.exceptions import BotoCoreError, ClientError, ParamValidationError +from botocore.exceptions import ( + BotoCoreError, + ClientError, + NoCredentialsError, + NoRegionError, + ParamValidationError, + PartialCredentialsError, + ProfileNotFound, + UnknownServiceError, +) from datetime import datetime, timedelta from fastmcp import Context from typing import Any, Dict, List, Optional, Tuple, Union @@ -40,6 +50,25 @@ # Configure logger for this module logger = get_logger(__name__) +# BotoCoreError subclasses that report a credential or configuration problem rather +# than a connection failure. Retrying the same request cannot fix them during the run, +# so handle_aws_error keeps error_type='aws_connection_error' (stable for callers) but +# sets retry_strategy='do_not_retry' explicitly; ErrorSignalingMiddleware keeps a +# retry_strategy that is already set. Real connection failures (EndpointConnectionError, +# ConnectTimeoutError, ReadTimeoutError, ConnectionClosedError) stay retryable. +_NON_RETRYABLE_BOTOCORE_ERRORS = ( + NoCredentialsError, + PartialCredentialsError, + NoRegionError, + ProfileNotFound, + UnknownServiceError, +) + +_HINT_AWS_CONFIGURATION = ( + 'AWS credentials or client configuration are missing or invalid. Do not retry; ' + 'ask the user to fix the AWS profile, credentials, or region.' +) + # Version for user agent tracking __version__ = '1.0.0' @@ -279,6 +308,11 @@ async def handle_aws_error( } ) + if isinstance(error, _NON_RETRYABLE_BOTOCORE_ERRORS): + # Credential/configuration failure: same error_type, but not retryable. + error_response['retry_strategy'] = DO_NOT_RETRY + error_response['hint'] = _HINT_AWS_CONFIGURATION + else: error_type = 'unknown_error' error_class = error.__class__.__name__ diff --git a/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py b/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py index 9b0fa89f75..ac93bc82da 100644 --- a/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py +++ b/src/billing-cost-management-mcp-server/tests/utilities/test_aws_service_base.py @@ -389,6 +389,88 @@ async def test_handle_param_validation_error(self): assert 'Unknown parameter in filter.Tags' in result['message'] assert 'MatchValue' in result['details'] + @pytest.mark.asyncio + async def test_param_validation_error_is_modify_request(self): + """ParamValidationError maps to modify_request, so the caller fixes the request.""" + from awslabs.billing_cost_management_mcp_server.utilities.error_contract import ( + MODIFY_REQUEST, + add_retry_guidance, + ) + from botocore.exceptions import ParamValidationError + + error = ParamValidationError(report='Missing required parameter in input: "TimePeriod"') + result = await handle_aws_error(AsyncMock(), error, 'GetCostAndUsage', 'Cost Explorer') + + assert 'retry_strategy' not in result + assert add_retry_guidance(result)['retry_strategy'] == MODIFY_REQUEST + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'error_factory', + [ + lambda e: e.NoCredentialsError(), + lambda e: e.PartialCredentialsError(provider='env', cred_var='AWS_SECRET_ACCESS_KEY'), + lambda e: e.NoRegionError(), + lambda e: e.ProfileNotFound(profile='missing'), + lambda e: e.UnknownServiceError(service_name='nope', known_service_names='ce'), + ], + ids=[ + 'NoCredentialsError', + 'PartialCredentialsError', + 'NoRegionError', + 'ProfileNotFound', + 'UnknownServiceError', + ], + ) + async def test_configuration_botocore_errors_are_do_not_retry(self, error_factory): + """Credential/configuration BotoCoreErrors keep error_type but are not retryable.""" + import botocore.exceptions as boto_exceptions + from awslabs.billing_cost_management_mcp_server.utilities.error_contract import ( + DO_NOT_RETRY, + add_retry_guidance, + ) + + error = error_factory(boto_exceptions) + result = await handle_aws_error(AsyncMock(), error, 'GetCostAndUsage', 'Cost Explorer') + + # error_type stays stable; retry_strategy is set explicitly and survives the middleware + assert result['error_type'] == 'aws_connection_error' + assert result['boto_error_type'] == type(error).__name__ + assert result['retry_strategy'] == DO_NOT_RETRY + assert 'Do not retry' in result['hint'] + assert add_retry_guidance(result)['retry_strategy'] == DO_NOT_RETRY + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'error_factory', + [ + lambda e: e.EndpointConnectionError(endpoint_url='https://ce.us-east-1.amazonaws.com'), + lambda e: e.ConnectTimeoutError(endpoint_url='https://ce.us-east-1.amazonaws.com'), + lambda e: e.ReadTimeoutError(endpoint_url='https://ce.us-east-1.amazonaws.com'), + lambda e: e.ConnectionClosedError(endpoint_url='https://ce.us-east-1.amazonaws.com'), + ], + ids=[ + 'EndpointConnectionError', + 'ConnectTimeoutError', + 'ReadTimeoutError', + 'ConnectionClosedError', + ], + ) + async def test_connection_botocore_errors_stay_retryable(self, error_factory): + """Real connection failures are still retry_same_request.""" + import botocore.exceptions as boto_exceptions + from awslabs.billing_cost_management_mcp_server.utilities.error_contract import ( + RETRY_SAME_REQUEST, + add_retry_guidance, + ) + + error = error_factory(boto_exceptions) + result = await handle_aws_error(AsyncMock(), error, 'GetCostAndUsage', 'Cost Explorer') + + assert result['error_type'] == 'aws_connection_error' + assert 'retry_strategy' not in result + assert add_retry_guidance(result)['retry_strategy'] == RETRY_SAME_REQUEST + class TestFormatResponse: """Tests for format_response function."""