Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/billing-cost-management-mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +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 `{<Dimensions|Tags|CostCategories>: {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.
Expand All @@ -62,6 +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` (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.
Expand Down
2 changes: 1 addition & 1 deletion src/billing-cost-management-mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,48 @@
- **Resource-level cost analysis** (last 14 days)
- **Multi-dimensional cost analysis** with complex grouping

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):
The top-level object has exactly ONE of these keys:
- Leaf keys: `Dimensions`, `Tags`, `CostCategories`
- Logical keys: `And` and `Or` (each takes an array of expressions), `Not` (takes one
expression)

Leaf shape:
`{"<Dimensions|Tags|CostCategories>": {"Key": "...", "Values": [...], "MatchOptions": [...]}}`

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.

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.

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"]}}`
- `{"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
Expand All @@ -82,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 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. 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

Expand All @@ -97,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 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. `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
Expand Down Expand Up @@ -127,7 +169,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. 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:
Expand All @@ -147,7 +189,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":"<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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Expand All @@ -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'
Expand Down Expand Up @@ -245,6 +274,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__
Expand All @@ -260,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__
Expand Down
Loading
Loading