├── img └── mcp_server.png ├── .gitignore ├── Dockerfile ├── pyproject.toml ├── chainlit.md ├── LICENSE ├── mcp_sse_client.py ├── langgraph_agent_mcp_sse_client.py ├── app.py ├── README.md └── server.py /img/mcp_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarora79/aws-cost-explorer-mcp-server/HEAD/img/mcp_server.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .chainlit/ 12 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | # install astral uv for Python dependencies 6 | RUN pip install uv 7 | 8 | # Copy project files 9 | COPY pyproject.toml . 10 | COPY server.py . 11 | 12 | # Install dependencies using uv 13 | RUN uv pip install --no-cache --system -e . 14 | 15 | # Add AWS configuration directory 16 | RUN mkdir -p /root/.aws 17 | 18 | # Expose port for SSE transport 19 | EXPOSE 8000 20 | 21 | # Run the MCP server 22 | CMD ["python", "server.py"] -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aws-cost-explorer-mcp" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12,<3.13" 7 | dependencies = [ 8 | "boto3>=1.37.9", 9 | "botocore>=1.37.9", 10 | "chainlit>=2.4.1", 11 | "jmespath>=1.0.1", 12 | "langchain>=0.3.20", 13 | "langchain-anthropic>=0.3.9", 14 | "langchain-aws>=0.2.15", 15 | "langchain-mcp-adapters>=0.0.4", 16 | "langgraph>=0.3.10", 17 | "mcp>=1.3.0", 18 | "pandas>=2.2.3", 19 | "pydantic>=2.10.6", 20 | "streamlit>=1.44.1", 21 | "tabulate>=0.9.0", 22 | "typing-extensions>=4.12.2", 23 | ] 24 | -------------------------------------------------------------------------------- /chainlit.md: -------------------------------------------------------------------------------- 1 | # Welcome to Chainlit! 🚀🤖 2 | 3 | Hi there, Developer! 👋 We're excited to have you on board. Chainlit is a powerful tool designed to help you prototype, debug and share applications built on top of LLMs. 4 | 5 | ## Useful Links 🔗 6 | 7 | - **Documentation:** Get started with our comprehensive [Chainlit Documentation](https://docs.chainlit.io) 📚 8 | - **Discord Community:** Join our friendly [Chainlit Discord](https://discord.gg/k73SQ3FyUh) to ask questions, share your projects, and connect with other developers! 💬 9 | 10 | We can't wait to see what you create with Chainlit! Happy coding! 💻😊 11 | 12 | ## Welcome screen 13 | 14 | To modify the welcome screen, edit the `chainlit.md` file at the root of your project. If you do not want a welcome screen, just leave this file empty. 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Amit Arora 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /mcp_sse_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file provides a simple MCP client using just the mcp Python package. 3 | It shows how to access the different MCP server capabilities (prompts, tools etc.) via the message types 4 | supported by the protocol. See: https://modelcontextprotocol.io/docs/concepts/architecture. 5 | 6 | Usage: 7 | python mcp_sse_client.py [--host HOSTNAME] [--port PORT] 8 | 9 | Example: 10 | python mcp_sse_client.py --host ec2-44-192-72-20.compute-1.amazonaws.com --port 8000 11 | """ 12 | import argparse 13 | from mcp import types 14 | from mcp import ClientSession 15 | from mcp.client.sse import sse_client 16 | 17 | # Optional: create a sampling callback 18 | async def handle_sampling_message(message: types.CreateMessageRequestParams) -> types.CreateMessageResult: 19 | return types.CreateMessageResult( 20 | role="assistant", 21 | content=types.TextContent( 22 | type="text", 23 | text="Hello, world! from model", 24 | ), 25 | model="gpt-3.5-turbo", 26 | stopReason="endTurn", 27 | ) 28 | 29 | async def run(server_url, args): 30 | print(f"Connecting to MCP server at: {server_url}") 31 | 32 | async with sse_client(server_url) as (read, write): 33 | async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: 34 | # Initialize the connection 35 | await session.initialize() 36 | 37 | # List available prompts 38 | prompts = await session.list_prompts() 39 | print("=" * 50) 40 | print("Available prompts:") 41 | print("=" * 50) 42 | print(prompts) 43 | print("=" * 50) 44 | 45 | # List available resources 46 | resources = await session.list_resources() 47 | print("=" * 50) 48 | print("Available resources:") 49 | print("=" * 50) 50 | print(resources) 51 | print("=" * 50) 52 | 53 | # List available tools 54 | tools = await session.list_tools() 55 | print("=" * 50) 56 | print("Available tools:") 57 | print("=" * 50) 58 | print(tools) 59 | print("=" * 50) 60 | 61 | # Call the Bedrock usage stats tool with command-line arguments 62 | days = 7 63 | region = 'us-east-1' 64 | print(f"\nCalling get_bedrock_daily_usage_stats tool with days={days}, region={region}:") 65 | result = await session.call_tool( 66 | "get_bedrock_daily_usage_stats", 67 | arguments={"params": {"days": days, "region": region, "aws_account_id": args.aws_account_id}} 68 | ) 69 | 70 | # Display the results 71 | print("=" * 50) 72 | print("Bedrock Usage Results:") 73 | print("=" * 50) 74 | for r in result.content: 75 | print(r.text) 76 | print("=" * 50) 77 | 78 | if __name__ == "__main__": 79 | # Set up command-line argument parsing 80 | parser = argparse.ArgumentParser(description='MCP Client for Bedrock Usage Statistics') 81 | parser.add_argument('--host', type=str, default='ec2-44-192-72-20.compute-1.amazonaws.com', 82 | help='Hostname of the MCP server') 83 | parser.add_argument('--port', type=int, default=8000, 84 | help='Port of the MCP server') 85 | parser.add_argument('--aws-account-id', type=str, default=None, 86 | help='AWS account id to monitor bedrock usage for if different from the current account in which the MCP server is running (requires cross-account access)') 87 | 88 | # Parse the arguments 89 | args = parser.parse_args() 90 | 91 | # Build the server 92 | secure = '' 93 | 94 | # Automatically turn to https if port is 443 95 | if args.port == 443: 96 | secure = 's' 97 | server_url = f"http{secure}://{args.host}:{args.port}/sse" 98 | 99 | # Run the async main function 100 | import asyncio 101 | asyncio.run(run(server_url, args)) -------------------------------------------------------------------------------- /langgraph_agent_mcp_sse_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | LangGraph MCP Client 4 | 5 | This script demonstrates using LangGraph with the MultiServerMCPClient adapter to connect to an 6 | MCP-compatible server and query information using a Bedrock-hosted Claude model. 7 | 8 | The script accepts command line arguments for: 9 | - Server host and port 10 | - Model ID to use 11 | - User message to process 12 | 13 | Usage: 14 | python langgraph_mcp_client.py --host hostname --port port --model model_id --message "your question" 15 | 16 | Example: 17 | python langgraph_mcp_sse_client.py --host ec2-44-192-72-20.compute-1.amazonaws.com --port 8000 \ 18 | --model anthropic.claude-3-haiku-20240307-v1:0 --message "my bedrock usage in last 7 days?" 19 | """ 20 | 21 | import asyncio 22 | import argparse 23 | from typing import Dict, List, Any, Optional 24 | from langchain_mcp_adapters.client import MultiServerMCPClient 25 | from langgraph.prebuilt import create_react_agent 26 | from langchain_aws import ChatBedrock 27 | 28 | def parse_arguments() -> argparse.Namespace: 29 | """ 30 | Parse command line arguments for the LangGraph MCP client. 31 | 32 | Returns: 33 | argparse.Namespace: The parsed command line arguments 34 | """ 35 | parser = argparse.ArgumentParser(description='LangGraph MCP Client') 36 | 37 | # Server connection arguments 38 | parser.add_argument('--host', type=str, default='ec2-44-192-72-20.compute-1.amazonaws.com', 39 | help='Hostname of the MCP server') 40 | parser.add_argument('--port', type=int, default=8000, 41 | help='Port of the MCP server') 42 | parser.add_argument('--server-name', type=str, default='aws_cost_explorer', 43 | help='Server name identifier in the configuration') 44 | parser.add_argument('--aws-account-id', type=str, default="", 45 | help='AWS account id to use for retrieving cost information, if not specified then the account in which the MCP server is running is used.') 46 | 47 | # Model arguments 48 | parser.add_argument('--model', type=str, default='us.anthropic.claude-3-5-haiku-20241022-v1:0', 49 | help='Model ID to use with Bedrock') 50 | 51 | # Message arguments 52 | parser.add_argument('--message', type=str, default='my bedrock usage in last 7 days? My account id is {}', 53 | help='Message to send to the agent') 54 | 55 | return parser.parse_args() 56 | 57 | async def main(): 58 | """ 59 | Main function that: 60 | 1. Parses command line arguments 61 | 2. Sets up the LangChain MCP client and Bedrock model 62 | 3. Creates a LangGraph agent with available tools 63 | 4. Invokes the agent with the provided message 64 | 5. Displays the response 65 | """ 66 | # Parse command line arguments 67 | args = parse_arguments() 68 | args.message = args.message.format(args.aws_account_id) 69 | # Display configuration 70 | secure = 's' if args.port == 443 else '' 71 | server_url = f"http{secure}://{args.host}:{args.port}/sse" 72 | print(f"Connecting to MCP server: {server_url}") 73 | print(f"Using model: {args.model}") 74 | print(f"Message: {args.message}") 75 | 76 | # Initialize the model 77 | model = ChatBedrock(model_id=args.model, region_name='us-east-1') 78 | 79 | try: 80 | # Initialize MCP client with the server configuration 81 | async with MultiServerMCPClient( 82 | { 83 | args.server_name: { 84 | "url": server_url, 85 | "transport": "sse", 86 | } 87 | } 88 | ) as client: 89 | print("Connected to MCP server successfully") 90 | 91 | # Get available prompt 92 | prompt = await client.get_prompt(args.server_name, "system_prompt_for_agent", dict(aws_account_id=args.aws_account_id)) 93 | print(f"Available prompt: {prompt}") 94 | system_prompt = prompt[0].content 95 | 96 | # Get available tools and display them 97 | tools = client.get_tools() 98 | print(f"Available tools: {[tool.name for tool in tools]}") 99 | 100 | # Create the agent with the model and tools 101 | agent = create_react_agent( 102 | model, 103 | tools 104 | ) 105 | 106 | # Format the message with system message first 107 | formatted_messages = [ 108 | {"role": "system", "content": system_prompt}, 109 | {"role": "user", "content": args.message} 110 | ] 111 | 112 | print("\nInvoking agent...\n" + "-"*40) 113 | 114 | # Invoke the agent with the formatted messages 115 | response = await agent.ainvoke({"messages": formatted_messages}) 116 | 117 | print("\nResponse:" + "\n" + "-"*40) 118 | 119 | # Process and display the response 120 | if response and "messages" in response and response["messages"]: 121 | # Get the last message from the response 122 | last_message = response["messages"][-1] 123 | 124 | if isinstance(last_message, dict) and "content" in last_message: 125 | # Display the content of the response 126 | print(last_message["content"]) 127 | else: 128 | print(str(last_message.content)) 129 | else: 130 | print("No valid response received") 131 | 132 | except Exception as e: 133 | print(f"Error: {str(e)}") 134 | import traceback 135 | print(traceback.format_exc()) 136 | 137 | if __name__ == "__main__": 138 | asyncio.run(main()) -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | AWS Cost Explorer Assistant 4 | 5 | This Chainlit application provides a conversational interface to help users 6 | explore and analyze their AWS costs. It uses Claude via Bedrock and integrates 7 | with an MCP server that provides tools for AWS cost analysis. 8 | 9 | The application maintains a conversation history to provide context-aware 10 | responses across multiple interactions. 11 | 12 | To configure the MCP server, set environment variables before running: 13 | export MCP_SERVER_URL=your-server-hostname 14 | export MCP_SERVER_PORT=your-server-port 15 | 16 | Example: 17 | export MCP_SERVER_URL=localhost 18 | export MCP_SERVER_PORT=8000 19 | chainlit run app.py --port 8080 20 | """ 21 | 22 | import os 23 | import chainlit as cl 24 | from langchain_aws import ChatBedrock 25 | from langgraph.prebuilt import create_react_agent 26 | from langchain_mcp_adapters.client import MultiServerMCPClient 27 | 28 | # Get configuration from environment variables with defaults 29 | MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "ec2-44-192-72-20.compute-1.amazonaws.com") 30 | MCP_SERVER_PORT = os.getenv("MCP_SERVER_PORT", "8000") 31 | SECURE = 's' if MCP_SERVER_PORT == "443" else '' 32 | FULL_MCP_URL = f"http{SECURE}://{MCP_SERVER_URL}:{MCP_SERVER_PORT}/sse" 33 | AWS_REGION = os.getenv("AWS_REGION", "us-east-1") 34 | AWS_ACCOUNT_ID = os.getenv("AWS_ACCOUNT_ID", "") 35 | 36 | # Print configuration at module load time 37 | print(f"MCP Server configured at: {FULL_MCP_URL}") 38 | print("To change this configuration, set the MCP_SERVER_URL and MCP_SERVER_PORT environment variables") 39 | 40 | # Initialize the model 41 | model = ChatBedrock(model="us.anthropic.claude-3-5-haiku-20241022-v1:0", region_name=AWS_REGION) 42 | 43 | @cl.on_chat_start 44 | async def start(): 45 | welcome_message = f""" 46 | # 👋 Welcome to your AWS cost explorer assistant. 47 | 48 | I'm ready to help you with your questions related to your AWS spend. How can I help you save today? 49 | 50 | _Connected to MCP server at: {MCP_SERVER_URL}:{MCP_SERVER_PORT}_ 51 | """ 52 | await cl.Message(content=welcome_message).send() 53 | 54 | # Initialize conversation history with a system message at the beginning 55 | cl.user_session.set( 56 | "message_history", 57 | [] # Start with an empty history - we'll add the system message when formatting for the agent 58 | ) 59 | 60 | @cl.on_message 61 | async def main(message: cl.Message): 62 | # Get the conversation history 63 | message_history = cl.user_session.get("message_history") 64 | 65 | # Add the current user message to history 66 | message_history.append({"role": "user", "content": message.content}) 67 | 68 | # Show a thinking message 69 | thinking_msg = cl.Message(content="Thinking...") 70 | await thinking_msg.send() 71 | 72 | try: 73 | async with MultiServerMCPClient( 74 | { 75 | "aws_cost_explorer_mcp_server": { 76 | "url": FULL_MCP_URL, 77 | "transport": "sse", 78 | } 79 | } 80 | ) as client: 81 | prompt = await client.get_prompt("aws_cost_explorer_mcp_server", "system_prompt_for_agent", dict(aws_account_id=AWS_ACCOUNT_ID)) 82 | print(f"Available prompt: {prompt}") 83 | system_prompt = prompt[0].content 84 | 85 | # Create the agent 86 | agent = create_react_agent( 87 | model, 88 | client.get_tools() 89 | ) 90 | 91 | # Format messages for the agent - ensure system message is first 92 | formatted_messages = [ 93 | {"role": "system", "content": system_prompt} 94 | ] 95 | # Add the rest of the conversation history 96 | formatted_messages.extend(message_history) 97 | 98 | # Invoke the agent with properly formatted message history 99 | print(f"Sending request to MCP server at: {FULL_MCP_URL}") 100 | print(f"formatted_messages={formatted_messages}") 101 | response = await agent.ainvoke({"messages": formatted_messages}) 102 | 103 | # Remove the thinking message 104 | await thinking_msg.remove() 105 | 106 | # Extract the content from the response 107 | if response and "messages" in response and response["messages"]: 108 | last_message = response["messages"][-1] 109 | 110 | if isinstance(last_message, dict) and "content" in last_message: 111 | content = last_message["content"] 112 | else: 113 | content = str(last_message.content) 114 | 115 | # Add the assistant's response to the conversation history 116 | message_history.append({"role": "assistant", "content": content}) 117 | 118 | # Save the updated history (without system message) 119 | cl.user_session.set("message_history", message_history) 120 | 121 | # Send the message 122 | await cl.Message(content=content).send() 123 | else: 124 | await cl.Message(content="No valid response received").send() 125 | 126 | except Exception as e: 127 | # Remove the thinking message 128 | await thinking_msg.remove() 129 | 130 | # Send error message 131 | error_message = f""" 132 | ## ❌ Error Occurred 133 | 134 | ``` 135 | {str(e)} 136 | ``` 137 | 138 | Please try again or check your query. 139 | """ 140 | await cl.Message(content=error_message, author="System").send() 141 | 142 | # Print error to console for debugging 143 | print(f"Error: {str(e)}") 144 | 145 | if __name__ == "__main__": 146 | cl.run( 147 | title="AWS Cost Explorer", 148 | description="An intelligent assistant for analyzing your AWS costs" 149 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Cost Explorer and Amazon Bedrock Model Invocation Logs MCP Server & Client 2 | 3 | An MCP server for getting AWS spend data via Cost Explorer and Amazon Bedrock usage data via [`Model invocation logs`](https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html) in Amazon Cloud Watch through [Anthropic's MCP (Model Control Protocol)](https://www.anthropic.com/news/model-context-protocol). See section on ["secure" remote MCP server](#secure-remote-mcp-server) to see how you can run your MCP server over HTTPS. 4 | 5 | ```mermaid 6 | flowchart LR 7 | User([User]) --> UserApp[User Application] 8 | UserApp --> |Queries| Host[Host] 9 | 10 | subgraph "Claude Desktop" 11 | Host --> MCPClient[MCP Client] 12 | end 13 | 14 | MCPClient --> |MCP Protocol over HTTPS| MCPServer[AWS Cost Explorer MCP Server] 15 | 16 | subgraph "AWS Services" 17 | MCPServer --> |API Calls| CostExplorer[(AWS Cost Explorer)] 18 | MCPServer --> |API Calls| CloudWatchLogs[(AWS CloudWatch Logs)] 19 | end 20 | ``` 21 | 22 | You can run the MCP server locally and access it via the Claude Desktop or you could also run a Remote MCP server on Amazon EC2 and access it via a MCP client built into a LangGraph Agent. 23 | 24 | 🚨You can also use this MCP server to get AWS spend information from other accounts as long as the IAM role used by the MCP server can assume roles in those other accounts🚨 25 | 26 | ### Demo video 27 | 28 | [![AWS Cost Explorer MCP Server Deep Dive](https://img.youtube.com/vi/WuVOmYLRFmI/maxresdefault.jpg)](https://youtu.be/WuVOmYLRFmI) 29 | 30 | ## Overview 31 | 32 | This tool provides a convenient way to analyze and visualize AWS cloud spending data using Anthropic's Claude model as an interactive interface. It functions as an MCP server that exposes AWS Cost Explorer API functionality to Claude Desktop, allowing you to ask questions about your AWS spend in natural language. 33 | 34 | ## Features 35 | 36 | - **Amazon EC2 Spend Analysis**: View detailed breakdowns of EC2 spending for the last day 37 | - **Amazon Bedrock Spend Analysis**: View breakdown by region, users and models over the last 30 days 38 | - **Service Spend Reports**: Analyze spending across all AWS services for the last 30 days 39 | - **Detailed Cost Breakdown**: Get granular cost data by day, region, service, and instance type 40 | - **Interactive Interface**: Use Claude to query your cost data through natural language 41 | 42 | ## Requirements 43 | 44 | - Python 3.12 45 | - AWS credentials with Cost Explorer access 46 | - Anthropic API access (for Claude integration) 47 | - [Optional] Amazon Bedrock access (for LangGraph Agent) 48 | - [Optional] Amazon EC2 for running a remote MCP server 49 | 50 | ## Installation 51 | 52 | 1. Install `uv`: 53 | ```bash 54 | # On macOS and Linux 55 | curl -LsSf https://astral.sh/uv/install.sh | sh 56 | ``` 57 | 58 | 59 | ```powershell 60 | # On Windows 61 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 62 | ``` 63 | Additional installation options are documented [here](https://docs.astral.sh/uv/getting-started/installation/) 64 | 65 | 2. Clone this repository: (assuming this will be updated to point to aws-samples?) 66 | ``` 67 | git clone https://github.com/aarora79/aws-cost-explorer-mcp.git 68 | cd aws-cost-explorer-mcp 69 | ``` 70 | 71 | 3. Set up the Python virtual environment and install dependencies: 72 | ``` 73 | uv venv --python 3.12 && source .venv/bin/activate && uv pip install --requirement pyproject.toml 74 | ``` 75 | 76 | 4. Configure your AWS credentials: 77 | ``` 78 | mkdir -p ~/.aws 79 | # Set up your credentials in ~/.aws/credentials and ~/.aws/config 80 | ``` 81 | If you useAWS IAM Identity Center, follow the [docs](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html) to configure your short-term credentials 82 | 83 | ## Usage 84 | 85 | ### Prerequisites 86 | 87 | 1. Setup [model invocation logs](https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html#setup-cloudwatch-logs-destination) in Amazon CloudWatch. 88 | 1. Ensure that the IAM user/role being used has full read-only access to Amazon Cost Explorer and Amazon CloudWatch, this is required for the MCP server to retrieve data from these services. 89 | See [here](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/billing-example-policies.html) and [here](https://docs.aws.amazon.com/aws-managed-policy/latest/reference/CloudWatchLogsReadOnlyAccess.html) for sample policy examples that you can use & modify as per your requirements. 90 | 1. To allow your MCP server to access AWS spend information from other accounts set the the `CROSS_ACCOUNT_ROLE_NAME` parameter while starting the server and now you can provide the account AWS account id for another account while interacting with your agent and then agent will pass the account id to the server. 91 | 92 | ### Local setup 93 | 94 | Uses `stdio` as a transport for MCP, both the MCP server and client are running on your local machine. 95 | 96 | #### Starting the Server (local) 97 | 98 | Run the server using: 99 | 100 | ``` 101 | export MCP_TRANSPORT=stdio 102 | export BEDROCK_LOG_GROUP_NAME=YOUR_BEDROCK_CW_LOG_GROUP_NAME 103 | export CROSS_ACCOUNT_ROLE_NAME=ROLE_NAME_FOR_THE_ROLE_TO_ASSUME_IN_OTHER_ACCOUNTS # can be ignored if you do not want AWS spend info from other accounts 104 | python server.py 105 | ``` 106 | 107 | #### Claude Desktop Configuration 108 | 109 | There are two ways to configure this tool with Claude Desktop: 110 | 111 | ##### Option 1: Using Docker 112 | 113 | Add the following to your Claude Desktop configuration file. The file can be found out these paths depending upon you operating system. 114 | 115 | - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. 116 | - Windows: %APPDATA%\Claude\claude_desktop_config.json. 117 | - Linux: ~/.config/Claude/claude_desktop_config.json. 118 | 119 | ```json 120 | { 121 | "mcpServers": { 122 | "aws-cost-explorer": { 123 | "command": "docker", 124 | "args": [ "run", "-i", "--rm", "-e", "AWS_ACCESS_KEY_ID", "-e", "AWS_SECRET_ACCESS_KEY", "-e", "AWS_REGION", "-e", "BEDROCK_LOG_GROUP_NAME", "-e", "MCP_TRANSPORT", "-e", "CROSS_ACCOUNT_ROLE_NAME", "aws-cost-explorer-mcp:latest" ], 125 | "env": { 126 | "AWS_ACCESS_KEY_ID": "YOUR_ACCESS_KEY_ID", 127 | "AWS_SECRET_ACCESS_KEY": "YOUR_SECRET_ACCESS_KEY", 128 | "AWS_REGION": "us-east-1", 129 | "BEDROCK_LOG_GROUP_NAME": "YOUR_CLOUDWATCH_BEDROCK_MODEL_INVOCATION_LOG_GROUP_NAME", 130 | "CROSS_ACCOUNT_ROLE_NAME": "ROLE_NAME_FOR_THE_ROLE_TO_ASSUME_IN_OTHER_ACCOUNTS", 131 | "MCP_TRANSPORT": "stdio" 132 | } 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | > **IMPORTANT**: Replace `YOUR_ACCESS_KEY_ID` and `YOUR_SECRET_ACCESS_KEY` with your actual AWS credentials. Never commit actual credentials to version control. 139 | 140 | ##### Option 2: Using UV (without Docker) 141 | 142 | If you prefer to run the server directly without Docker, you can use UV: 143 | 144 | ```json 145 | { 146 | "mcpServers": { 147 | "aws_cost_explorer": { 148 | "command": "uv", 149 | "args": [ 150 | "--directory", 151 | "/path/to/aws-cost-explorer-mcp-server", 152 | "run", 153 | "server.py" 154 | ], 155 | "env": { 156 | "AWS_ACCESS_KEY_ID": "YOUR_ACCESS_KEY_ID", 157 | "AWS_SECRET_ACCESS_KEY": "YOUR_SECRET_ACCESS_KEY", 158 | "AWS_REGION": "us-east-1", 159 | "BEDROCK_LOG_GROUP_NAME": "YOUR_CLOUDWATCH_BEDROCK_MODEL_INVOCATION_LOG_GROUP_NAME", 160 | "CROSS_ACCOUNT_ROLE_NAME": "ROLE_NAME_FOR_THE_ROLE_TO_ASSUME_IN_OTHER_ACCOUNTS", 161 | "MCP_TRANSPORT": "stdio" 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | Make sure to replace the directory path with the actual path to your repository on your system. 169 | 170 | ### Remote setup 171 | 172 | Uses `sse` as a transport for MCP, the MCP servers on EC2 and the client is running on your local machine. Note that Claude Desktop does not support remote MCP servers at this time (see [this](https://github.com/orgs/modelcontextprotocol/discussions/16) GitHub issue). 173 | 174 | #### Starting the Server (remote) 175 | 176 | You can start a remote MCP server on Amazon EC2 by following the same instructions as above. Make sure to set the `MCP_TRANSPORT` as `sse` (server side events) as shown below. **Note that the MCP uses JSON-RPC 2.0 as its wire format, therefore the protocol itself does not include authorization and authentication (see [this GitHub issue](https://github.com/modelcontextprotocol/specification/discussions/102)), do not send or receive sensitive data over MCP**. 177 | 178 | Run the server using: 179 | 180 | ``` 181 | export MCP_TRANSPORT=sse 182 | export BEDROCK_LOG_GROUP_NAME=YOUR_BEDROCK_CW_LOG_GROUP_NAME 183 | export CROSS_ACCOUNT_ROLE_NAME=ROLE_NAME_FOR_THE_ROLE_TO_ASSUME_IN_OTHER_ACCOUNTS # can be ignored if you do not want AWS spend info from other accounts 184 | python server.py 185 | ``` 186 | 187 | 1. The MCP server will start listening on TCP port 8000. 188 | 1. Configure an ingress rule in the security group associated with your EC2 instance to allow access to TCP port 8000 from your local machine (where you are running the MCP client/LangGraph based app) to your EC2 instance. 189 | 190 | >Also see section on running a ["secure" remote MCP server](#secure-remote-mcp-server) i.e. a server to which your MCP clients can connect over HTTPS. 191 | 192 | #### Testing with a CLI MCP client 193 | 194 | You can test your remote MCP server with the `mcp_sse_client.py` script. Running this script will print the list of tools available from the MCP server and an output for the `get_bedrock_daily_usage_stats` tool. 195 | 196 | ```{.bashrc} 197 | # set the hostname for your MCP server 198 | MCP_SERVER_HOSTNAME=YOUR_MCP_SERVER_EC2_HOSTNAME 199 | # or localhost if your MCP server is running locally 200 | # MCP_SERVER_HOSTNAME=localhost 201 | AWS_ACCOUNT_ID=AWS_ACCOUNT_ID_TO_GET_INFO_ABOUT # if set to empty or if the --aws-account-id switch is not specified then it gets the info about the AWS account MCP server is running in 202 | python mcp_sse_client.py --host $MCP_SERVER_HOSTNAME --aws-account-id $AWS_ACCOUNT_ID 203 | ``` 204 | 205 | 206 | #### Testing with Chainlit app 207 | 208 | The `app.py` file in this repo provides a Chainlit app (chatbot) which creates a LangGraph agent that uses the [`LangChain MCP Adapter`](https://github.com/langchain-ai/langchain-mcp-adapters) to import the tools provided by the MCP server as tools in a LangGraph Agent. The Agent is then able to use an LLM to respond to user questions and use the tools available to it as needed. Thus if the user asks a question such as "_What was my Bedrock usage like in the last one week?_" then the Agent will use the tools available to it via the remote MCP server to answer that question. We use Claude 3.5 Haiku model available via Amazon Bedrock to power this agent. 209 | 210 | Run the Chainlit app using: 211 | 212 | ```{.bashrc} 213 | chainlit run app.py --port 8080 214 | ``` 215 | 216 | A browser window should open up on `localhost:8080` and you should be able to use the chatbot to get details about your AWS spend. 217 | 218 | ### Available Tools 219 | 220 | The server exposes the following tools that Claude can use: 221 | 222 | 1. **`get_ec2_spend_last_day()`**: Retrieves EC2 spending data for the previous day 223 | 1. **`get_detailed_breakdown_by_day(days=7)`**: Delivers a comprehensive analysis of costs by region, service, and instance type 224 | 1. **`get_bedrock_daily_usage_stats(days=7, region='us-east-1', log_group_name='BedrockModelInvocationLogGroup')`**: Delivers a per-day breakdown of model usage by region and users. 225 | 1. **`get_bedrock_hourly_usage_stats(days=7, region='us-east-1', log_group_name='BedrockModelInvocationLogGroup')`**: Delivers a per-day per-hour breakdown of model usage by region and users. 226 | 227 | ### Example Queries 228 | 229 | Once connected to Claude through an MCP-enabled interface, you can ask questions like: 230 | 231 | - "Help me understand my Bedrock spend over the last few weeks" 232 | - "What was my EC2 spend yesterday?" 233 | - "Show me my top 5 AWS services by cost for the last month" 234 | - "Analyze my spending by region for the past 14 days" 235 | - "Which instance types are costing me the most money?" 236 | - "Which services had the highest month-over-month cost increase?" 237 | 238 | ## Docker Support 239 | 240 | A Dockerfile is included for containerized deployment: 241 | 242 | ``` 243 | docker build -t aws-cost-explorer-mcp . 244 | docker run -v ~/.aws:/root/.aws aws-cost-explorer-mcp 245 | ``` 246 | 247 | ## Development 248 | 249 | ### Project Structure 250 | 251 | - `server.py`: Main server implementation with MCP tools 252 | - `pyproject.toml`: Project dependencies and metadata 253 | - `Dockerfile`: Container definition for deployments 254 | 255 | ### Adding New Cost Analysis Tools 256 | 257 | To extend the functionality: 258 | 259 | 1. Add new functions to `server.py` 260 | 2. Annotate them with `@mcp.tool()` 261 | 3. Implement the AWS Cost Explorer API calls 262 | 4. Format the results for easy readability 263 | 264 | ## Secure "remote" MCP server 265 | 266 | We can use [`nginx`](https://nginx.org/) as a reverse-proxy so that it can provide an HTTPS endpoint for connecting to the MCP server. Remote MCP clients can connect to `nginx` over HTTPS and then it can proxy traffic internally to `http://localhost:8000`. The following steps describe how to do this. 267 | 268 | 1. Enable access to TCP port 443 from the IP address of your MCP client (your laptop, or anywhere) in the inbound rules in the security group associated with your EC2 instance. 269 | 270 | 1. You would need to have an HTTPS certificate and private key to proceed. Let's say you use `your-mcp-server-domain-name.com` as the domain for your MCP server then you will need an SSL cert for `your-mcp-server-domain-name.com` and it will be accessible to MCP clients as `https://your-mcp-server-domain-name.com/sse`. _While you can use a self-signed cert but it would require disabling SSL verification on the MCP client, we DO NOT recommend you do that_. If you are hosting your MCP server on EC2 then you could generate an SSL cert using [no-ip](https://www.noip.com/) or [Let' Encrypt](https://letsencrypt.org/) or other similar services. Place the SSL cert and private key files in `/etc/ssl/certs` and `/etc/ssl/privatekey` folders respectively on your EC2 machine. 271 | 272 | 1. Install `nginx` on your EC2 machine using the following commands. 273 | 274 | ```{.bashrc} 275 | sudo apt-get install nginx 276 | sudo nginx -t 277 | sudo systemctl reload nginx 278 | ``` 279 | 280 | 1. Get the hostname for your EC2 instance, this would be needed for configuring the `nginx` reverse proxy. 281 | 282 | ```{.bashrc} 283 | TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") && curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/public-hostname 284 | ``` 285 | 286 | 1. Copy the following content into a new file `/etc/nginx/conf.d/ec2.conf`. Replace `YOUR_EC2_HOSTNAME`, `/etc/ssl/certs/cert.pem` and `/etc/ssl/privatekey/privkey.pem` with values appropriate for your setup. 287 | 288 | ```{.bashrc} 289 | server { 290 | listen 80; 291 | server_name YOUR_EC2_HOSTNAME; 292 | 293 | # Optional: Redirect HTTP to HTTPS 294 | return 301 https://$host$request_uri; 295 | } 296 | 297 | server { 298 | listen 443 ssl; 299 | server_name YOUR_EC2_HOSTNAME; 300 | 301 | # Self-signed certificate paths 302 | ssl_certificate /etc/ssl/certs/cert.pem; 303 | ssl_certificate_key /etc/ssl/privatekey/privkey.pem; 304 | 305 | # Optional: Good practice 306 | ssl_protocols TLSv1.2 TLSv1.3; 307 | ssl_ciphers HIGH:!aNULL:!MD5; 308 | 309 | location / { 310 | # Reverse proxy to your local app (e.g., port 8000) 311 | proxy_pass http://127.0.0.1:8000; 312 | proxy_http_version 1.1; 313 | proxy_set_header Host $host; 314 | proxy_set_header X-Real-IP $remote_addr; 315 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 316 | } 317 | } 318 | 319 | ``` 320 | 321 | 1. Restart `nginx`. 322 | 323 | ```{.bashrc} 324 | sudo systemctl start nginx 325 | ``` 326 | 327 | 1. Start your MCP server as usual as described in the [remote setup](#remote-setup) section. 328 | 329 | 1. Your MCP server is now accessible over HTTPS as `https://your-mcp-server-domain-name.com/sse` to your MCP client. 330 | 331 | 1. On the client side now (say on your laptop or in your Agent) configure your MCP client to communicate to your MCP server as follows. 332 | 333 | ```{.bashrc} 334 | MCP_SERVER_HOSTNAME=YOUR_MCP_SERVER_DOMAIN_NAME 335 | AWS_ACCOUNT_ID=AWS_ACCOUNT_ID_TO_GET_INFO_ABOUT # if set to empty or if the --aws-account-id switch is not specified then it gets the info about the AWS account MCP server is running in 336 | python mcp_sse_client.py --host $MCP_SERVER_HOSTNAME --port 443 --aws-account-id $AWS_ACCOUNT_ID 337 | ``` 338 | 339 | Similarly you could run the chainlit app to talk to remote MCP server over HTTPS. 340 | 341 | ```{.bashrc} 342 | export MCP_SERVER_URL=YOUR_MCP_SERVER_DOMAIN_NAME 343 | export MCP_SERVER_PORT=443 344 | chainlit run app.py --port 8080 345 | ``` 346 | 347 | Similarly you could run the LangGraph Agent to talk to remote MCP server over HTTPS. 348 | 349 | ```{.bashrc} 350 | python langgraph_agent_mcp_sse_client.py --host $MCP_SERVER_HOSTNAME --port 443 --aws-account-id $AWS_ACCOUNT_ID 351 | ``` 352 | 353 | ## License 354 | 355 | [MIT License](LICENSE) 356 | 357 | ## Acknowledgments 358 | 359 | - This tool uses Anthropic's MCP framework 360 | - Powered by AWS Cost Explorer API 361 | - Built with [FastMCP](https://github.com/jlowin/fastmcp) for server implementation 362 | - README was generated by providing a text dump of the repo via [GitIngest](https://gitingest.com/) to Claude 363 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | """ 2 | AWS Cost Explorer MCP Server. 3 | 4 | This server provides MCP tools to interact with AWS Cost Explorer API. 5 | """ 6 | import os 7 | import argparse 8 | from collections import defaultdict 9 | from datetime import datetime, timedelta 10 | from typing import Any, Dict, List, Optional, Union 11 | 12 | import boto3 13 | import pandas as pd 14 | import json 15 | from mcp.server.fastmcp import FastMCP 16 | from pydantic import BaseModel, Field 17 | from tabulate import tabulate 18 | 19 | 20 | 21 | class DaysParam(BaseModel): 22 | """Parameters for specifying the number of days to look back.""" 23 | 24 | days: int = Field( 25 | default=7, 26 | description="Number of days to look back for cost data" 27 | ) 28 | 29 | 30 | 31 | class BedrockLogsParams(BaseModel): 32 | """Parameters for retrieving Bedrock invocation logs.""" 33 | days: int = Field( 34 | default=7, 35 | description="Number of days to look back for Bedrock logs", 36 | ge=1, 37 | le=90 38 | ) 39 | region: str = Field( 40 | default="us-east-1", 41 | description="AWS region to retrieve logs from" 42 | ) 43 | log_group_name: str = Field( 44 | description="Bedrock Log Group Name", 45 | default=os.environ.get('BEDROCK_LOG_GROUP_NAME', 'BedrockModelInvocationLogGroup') 46 | ) 47 | aws_account_id: Optional[str] = Field( 48 | description="AWS account id (if different from the current AWS account) of the account for which to get the cost data", 49 | default=None 50 | ) 51 | 52 | class EC2Params(BaseModel): 53 | """Parameters for retrieving EC2 Cost Explorer information.""" 54 | days: int = Field( 55 | default=1, 56 | description="Number of days to look back for Bedrock logs", 57 | ge=1, 58 | le=90 59 | ) 60 | region: str = Field( 61 | default="us-east-1", 62 | description="AWS region to retrieve logs from" 63 | ) 64 | aws_account_id: Optional[str] = Field( 65 | description="AWS account id (if different from the current AWS account) of the account for which to get the cost data", 66 | default=None 67 | ) 68 | 69 | # global params 70 | # if we want to get AWS spend info from a different account we need to assume a role in that account 71 | # and while the account id would be provided by the user of this MCP server, we set the name of the role 72 | # to assume in this code through an environ variable 73 | CROSS_ACCOUNT_ROLE_NAME: str = os.environ.get('CROSS_ACCOUNT_ROLE_NAME', "BedrockCrossAccount2") 74 | 75 | def get_aws_service_boto3_client(service: str, aws_account_id: Optional[str], region_name: str, account_b_role_name: Optional[str] = CROSS_ACCOUNT_ROLE_NAME): 76 | """ 77 | Creates a boto3 client for the specified service in this current AWS account or in a different account 78 | if an account id is specified. 79 | 80 | Args: 81 | service (str): AWS service name (e.g., 'logs', 'cloudwatch') 82 | region_name (str): AWS region (e.g. 'us-east-1') 83 | aws_account_id (str): AWS account ID to access, this is the account in which the role is to be assumed 84 | account_b_role_name (str): IAM role name to assume 85 | 86 | Returns: 87 | boto3.client: Service client with assumed role credentials 88 | """ 89 | try: 90 | this_account = boto3.client('sts').get_caller_identity()['Account'] 91 | if aws_account_id is not None and this_account != aws_account_id: 92 | # the request is for a different account, we need to assume a role in that account 93 | print(f"Request is for a different account: {aws_account_id}, current account: {this_account}") 94 | # Create STS client 95 | sts_client = boto3.client('sts') 96 | current_identity = sts_client.get_caller_identity() 97 | print(f"Current identity: {current_identity}") 98 | 99 | # Define the role ARN 100 | role_arn = f"arn:aws:iam::{aws_account_id}:role/{account_b_role_name}" 101 | print(f"Attempting to assume role: {role_arn}") 102 | 103 | # Assume the role 104 | assumed_role = sts_client.assume_role( 105 | RoleArn=role_arn, 106 | RoleSessionName="CrossAccountSession" 107 | ) 108 | 109 | # Extract temporary credentials 110 | credentials = assumed_role['Credentials'] 111 | 112 | # Create client with assumed role credentials 113 | client = boto3.client( 114 | service, 115 | region_name=region_name, 116 | aws_access_key_id=credentials['AccessKeyId'], 117 | aws_secret_access_key=credentials['SecretAccessKey'], 118 | aws_session_token=credentials['SessionToken'] 119 | ) 120 | 121 | print(f"Successfully created cross-account client for {service} in account {aws_account_id}") 122 | return client 123 | else: 124 | client = boto3.client( 125 | service, 126 | region_name=region_name 127 | ) 128 | 129 | print(f"Successfully created client for {service} in the current AWS account {this_account}") 130 | return client 131 | 132 | except Exception as e: 133 | print(f"Error creating cross-account client for {service}: {e}") 134 | raise e 135 | 136 | def get_bedrock_logs(params: BedrockLogsParams) -> Optional[pd.DataFrame]: 137 | """ 138 | Retrieve Bedrock invocation logs for the last n days in a given region as a dataframe 139 | 140 | Args: 141 | params: Pydantic model containing parameters: 142 | - days: Number of days to look back (default: 7) 143 | - region: AWS region to query (default: us-east-1) 144 | 145 | Returns: 146 | pd.DataFrame: DataFrame containing the log data with columns: 147 | - timestamp: Timestamp of the invocation 148 | - region: AWS region 149 | - modelId: Bedrock model ID 150 | - userId: User ARN 151 | - inputTokens: Number of input tokens 152 | - completionTokens: Number of completion tokens 153 | - totalTokens: Total tokens used 154 | """ 155 | # Initialize CloudWatch Logs client 156 | print(f"get_bedrock_logs, params={params}") 157 | client = get_aws_service_boto3_client("logs", params.aws_account_id, params.region) 158 | 159 | # Calculate time range 160 | end_time = datetime.now() 161 | start_time = end_time - timedelta(days=params.days) 162 | 163 | # Convert to milliseconds since epoch 164 | start_time_ms = int(start_time.timestamp() * 1000) 165 | end_time_ms = int(end_time.timestamp() * 1000) 166 | 167 | filtered_logs = [] 168 | 169 | try: 170 | paginator = client.get_paginator("filter_log_events") 171 | 172 | # Parameters for the log query 173 | query_params = { 174 | "logGroupName": params.log_group_name, # Use the provided log group name 175 | "logStreamNames": [ 176 | "aws/bedrock/modelinvocations" 177 | ], # The specific log stream 178 | "startTime": start_time_ms, 179 | "endTime": end_time_ms, 180 | } 181 | 182 | # Paginate through results 183 | for page in paginator.paginate(**query_params): 184 | for event in page.get("events", []): 185 | try: 186 | # Parse the message as JSON 187 | 188 | message = json.loads(event["message"]) 189 | 190 | # Get user prompt from the input messages 191 | prompt = "" 192 | 193 | input = message.get("input", {}) 194 | input_json = input.get("inputBodyJson", {}) 195 | messages = input_json.get("messages", None) 196 | 197 | if messages: 198 | for msg in message["input"]["inputBodyJson"]["messages"]: 199 | #print(f"debug 2.2, {type(msg)}") 200 | if msg.get("role") == "user" and msg.get("content"): 201 | for content in msg["content"]: 202 | 203 | if isinstance(content, dict): 204 | if content.get("text"): 205 | prompt += content["text"] + " " 206 | else: 207 | prompt += content 208 | 209 | prompt = prompt.strip() 210 | 211 | # Extract only the required fields 212 | 213 | filtered_event = { 214 | "timestamp": message.get("timestamp"), 215 | "region": message.get("region"), 216 | "modelId": message.get("modelId"), 217 | "userId": message.get("identity", {}).get("arn"), 218 | "inputTokens": message.get("input", {}).get("inputTokenCount"), 219 | "completionTokens": message.get("output", {}).get( 220 | "outputTokenCount" 221 | ), 222 | "totalTokens": ( 223 | message.get("input", {}).get("inputTokenCount", 0) 224 | + message.get("output", {}).get("outputTokenCount", 0) 225 | ), 226 | } 227 | 228 | filtered_logs.append(filtered_event) 229 | except json.JSONDecodeError: 230 | continue # Skip non-JSON messages 231 | except KeyError: 232 | continue # Skip messages missing required fields 233 | 234 | # Create DataFrame if we have logs 235 | if filtered_logs: 236 | df = pd.DataFrame(filtered_logs) 237 | df["timestamp"] = pd.to_datetime(df["timestamp"]) 238 | return df 239 | else: 240 | print("No logs found for the specified time period.") 241 | return None 242 | 243 | except client.exceptions.ResourceNotFoundException: 244 | print( 245 | f"Log group '{params.log_group_name}' or stream 'aws/bedrock/modelinvocations' not found" 246 | ) 247 | return None 248 | except Exception as e: 249 | print(f"Error retrieving logs: {str(e)}") 250 | return None 251 | 252 | 253 | 254 | # Initialize FastMCP server 255 | mcp = FastMCP("aws_cloudwatch_logs") 256 | @mcp.prompt() 257 | def system_prompt_for_agent(aws_account_id: str = "") -> str: 258 | """ 259 | Generates a system prompt for an AWS cost analysis agent. 260 | 261 | This function creates a specialized prompt for an AI agent that analyzes 262 | AWS cloud spending. The prompt instructs the agent on how to retrieve, 263 | analyze, and present cost optimization insights for AWS accounts. 264 | 265 | Args: 266 | aws_account_id (Optional[str]): The AWS account ID to analyze. 267 | If provided, the agent will focus on this specific account. 268 | If None, the agent will function without account-specific context. 269 | 270 | Returns: 271 | str: A formatted system prompt for the AWS cost analysis agent. 272 | """ 273 | if aws_account_id == "": 274 | aws_account_id = boto3.client('sts').get_caller_identity()['Account'] 275 | account_context = f"for account {aws_account_id}" 276 | initial_line = f"You are an expert AWS cost analyst AI agent {account_context}." 277 | second_line = f"Your purpose is to help users understand and optimize their AWS cloud spending for this account." 278 | 279 | system_prompt = f""" 280 | {initial_line} {second_line} You have access to the following tools: 281 | 282 | 1. AWS Cost Explorer data retrieval 283 | 2. CloudWatch logs analysis 284 | 3. Resource tagging information 285 | 4. Billing data by account, service, and region 286 | 5. Historical spend pattern analysis 287 | 288 | When a user asks about their AWS costs: 289 | 290 | 1. First, retrieve relevant data using your tools 291 | 2. Analyze spending patterns across services, users, applications, and time periods 292 | 3. Identify: 293 | - Highest cost services and resources 294 | - Unused or underutilized resources 295 | - Spending anomalies and unexpected increases 296 | - Resources lacking proper cost allocation tags 297 | - Opportunities for reserved instances or savings plans 298 | - Potential architectural optimizations 299 | 300 | 4. Present findings in a clear, actionable format with: 301 | - Visual breakdowns of cost distribution 302 | - Specific recommendations for cost optimization 303 | - Estimated potential savings for each recommendation 304 | - Comparative analysis with previous time periods 305 | 306 | Respond to queries about specific services, accounts, or time periods with precise, data-backed insights. Always provide practical recommendations that balance cost optimization with operational requirements. 307 | """ 308 | return system_prompt 309 | 310 | @mcp.tool() 311 | def get_bedrock_daily_usage_stats(params: BedrockLogsParams) -> str: 312 | """ 313 | Get daily usage statistics with detailed breakdowns. 314 | 315 | Args: 316 | params: Parameters specifying the number of days to look back and region 317 | 318 | Returns: 319 | str: Formatted string representation of daily usage statistics 320 | """ 321 | print(f"get_bedrock_daily_usage_stats, params={params}") 322 | df = get_bedrock_logs(params) 323 | 324 | if df is None or df.empty: 325 | return "No usage data found for the specified period." 326 | 327 | # Initialize result string 328 | result_parts = [] 329 | 330 | # Add header 331 | result_parts.append(f"Bedrock Usage Statistics (Past {params.days} days - {params.region})") 332 | result_parts.append("=" * 80) 333 | 334 | # Add a date column for easier grouping 335 | df['date'] = df['timestamp'].dt.date 336 | 337 | # === REGION -> MODEL GROUPING === 338 | result_parts.append("\n=== Daily Region-wise -> Model-wise Analysis ===") 339 | 340 | # Group by date, region, model and calculate metrics 341 | region_model_stats = df.groupby(['date', 'region', 'modelId']).agg({ 342 | 'inputTokens': ['count', 'sum', 'mean', 'max', 'median'], 343 | 'completionTokens': ['sum', 'mean', 'max', 'median'], 344 | 'totalTokens': ['sum', 'mean', 'max', 'median'] 345 | }) 346 | 347 | # Flatten the column multi-index 348 | region_model_stats.columns = [f"{col[0]}_{col[1]}" for col in region_model_stats.columns] 349 | 350 | # Reset the index to get a flat dataframe 351 | flattened_stats = region_model_stats.reset_index() 352 | 353 | # Rename inputTokens_count to request_count 354 | flattened_stats = flattened_stats.rename(columns={'inputTokens_count': 'request_count'}) 355 | 356 | # Add the flattened stats to result 357 | result_parts.append(flattened_stats.to_string(index=False)) 358 | 359 | # Add summary statistics 360 | result_parts.append("\n=== Summary Statistics ===") 361 | 362 | # Total requests and tokens 363 | total_requests = flattened_stats['request_count'].sum() 364 | total_input_tokens = flattened_stats['inputTokens_sum'].sum() 365 | total_completion_tokens = flattened_stats['completionTokens_sum'].sum() 366 | total_tokens = flattened_stats['totalTokens_sum'].sum() 367 | 368 | result_parts.append(f"Total Requests: {total_requests:,}") 369 | result_parts.append(f"Total Input Tokens: {total_input_tokens:,}") 370 | result_parts.append(f"Total Completion Tokens: {total_completion_tokens:,}") 371 | result_parts.append(f"Total Tokens: {total_tokens:,}") 372 | 373 | # === REGION SUMMARY === 374 | result_parts.append("\n=== Region Summary ===") 375 | region_summary = df.groupby('region').agg({ 376 | 'inputTokens': ['count', 'sum'], 377 | 'completionTokens': ['sum'], 378 | 'totalTokens': ['sum'] 379 | }) 380 | 381 | # Flatten region summary columns 382 | region_summary.columns = [f"{col[0]}_{col[1]}" for col in region_summary.columns] 383 | region_summary = region_summary.reset_index() 384 | region_summary = region_summary.rename(columns={'inputTokens_count': 'request_count'}) 385 | 386 | result_parts.append(region_summary.to_string(index=False)) 387 | 388 | # === MODEL SUMMARY === 389 | result_parts.append("\n=== Model Summary ===") 390 | model_summary = df.groupby('modelId').agg({ 391 | 'inputTokens': ['count', 'sum'], 392 | 'completionTokens': ['sum'], 393 | 'totalTokens': ['sum'] 394 | }) 395 | 396 | # Flatten model summary columns 397 | model_summary.columns = [f"{col[0]}_{col[1]}" for col in model_summary.columns] 398 | model_summary = model_summary.reset_index() 399 | model_summary = model_summary.rename(columns={'inputTokens_count': 'request_count'}) 400 | 401 | # Format model IDs to be more readable 402 | model_summary['modelId'] = model_summary['modelId'].apply( 403 | lambda model: model.split('.')[-1] if '.' in model else model.split('/')[-1] 404 | ) 405 | 406 | result_parts.append(model_summary.to_string(index=False)) 407 | 408 | # === USER SUMMARY === 409 | if 'userId' in df.columns: 410 | result_parts.append("\n=== User Summary ===") 411 | user_summary = df.groupby('userId').agg({ 412 | 'inputTokens': ['count', 'sum'], 413 | 'completionTokens': ['sum'], 414 | 'totalTokens': ['sum'] 415 | }) 416 | 417 | # Flatten user summary columns 418 | user_summary.columns = [f"{col[0]}_{col[1]}" for col in user_summary.columns] 419 | user_summary = user_summary.reset_index() 420 | user_summary = user_summary.rename(columns={'inputTokens_count': 'request_count'}) 421 | 422 | result_parts.append(user_summary.to_string(index=False)) 423 | 424 | # === REGION -> USER -> MODEL DETAILED SUMMARY === 425 | if 'userId' in df.columns: 426 | result_parts.append("\n=== Region -> User -> Model Detailed Summary ===") 427 | region_user_model_summary = df.groupby(['region', 'userId', 'modelId']).agg({ 428 | 'inputTokens': ['count', 'sum', 'mean'], 429 | 'completionTokens': ['sum', 'mean'], 430 | 'totalTokens': ['sum', 'mean'] 431 | }) 432 | 433 | # Flatten columns 434 | region_user_model_summary.columns = [f"{col[0]}_{col[1]}" for col in region_user_model_summary.columns] 435 | region_user_model_summary = region_user_model_summary.reset_index() 436 | region_user_model_summary = region_user_model_summary.rename(columns={'inputTokens_count': 'request_count'}) 437 | 438 | # Format model IDs to be more readable 439 | region_user_model_summary['modelId'] = region_user_model_summary['modelId'].apply( 440 | lambda model: model.split('.')[-1] if '.' in model else model.split('/')[-1] 441 | ) 442 | 443 | result_parts.append(region_user_model_summary.to_string(index=False)) 444 | 445 | 446 | # Combine all parts into a single string 447 | result = "\n".join(result_parts) 448 | 449 | return result 450 | 451 | @mcp.tool() 452 | def get_bedrock_hourly_usage_stats(params: BedrockLogsParams) -> str: 453 | """ 454 | Get hourly usage statistics with detailed breakdowns. 455 | 456 | Args: 457 | params: Parameters specifying the number of days to look back and region 458 | 459 | Returns: 460 | str: Formatted string representation of hourly usage statistics 461 | """ 462 | print(f"get_bedrock_hourly_usage_stats, params={params}") 463 | df = get_bedrock_logs(params) 464 | 465 | if df is None or df.empty: 466 | return "No usage data found for the specified period." 467 | 468 | # Initialize result string 469 | result_parts = [] 470 | 471 | # Add header 472 | result_parts.append(f"Hourly Bedrock Usage Statistics (Past {params.days} days - {params.region})") 473 | result_parts.append("=" * 80) 474 | 475 | # Add date and hour columns for easier grouping 476 | df['date'] = df['timestamp'].dt.date 477 | df['hour'] = df['timestamp'].dt.hour 478 | df['datetime'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:00') 479 | 480 | # === HOURLY USAGE ANALYSIS === 481 | result_parts.append("\n=== Hourly Usage Analysis ===") 482 | 483 | # Group by datetime (date + hour) 484 | hourly_stats = df.groupby('datetime').agg({ 485 | 'inputTokens': ['count', 'sum', 'mean'], 486 | 'completionTokens': ['sum', 'mean'], 487 | 'totalTokens': ['sum', 'mean'] 488 | }) 489 | 490 | # Flatten the column multi-index 491 | hourly_stats.columns = [f"{col[0]}_{col[1]}" for col in hourly_stats.columns] 492 | 493 | # Reset the index to get a flat dataframe 494 | hourly_stats = hourly_stats.reset_index() 495 | 496 | # Rename inputTokens_count to request_count 497 | hourly_stats = hourly_stats.rename(columns={'inputTokens_count': 'request_count'}) 498 | 499 | # Add the hourly stats to result 500 | result_parts.append(hourly_stats.to_string(index=False)) 501 | 502 | # === HOURLY REGION -> MODEL GROUPING === 503 | result_parts.append("\n=== Hourly Region-wise -> Model-wise Analysis ===") 504 | 505 | # Group by datetime, region, model and calculate metrics 506 | hourly_region_model_stats = df.groupby(['datetime', 'region', 'modelId']).agg({ 507 | 'inputTokens': ['count', 'sum', 'mean', 'max', 'median'], 508 | 'completionTokens': ['sum', 'mean', 'max', 'median'], 509 | 'totalTokens': ['sum', 'mean', 'max', 'median'] 510 | }) 511 | 512 | # Flatten the column multi-index 513 | hourly_region_model_stats.columns = [f"{col[0]}_{col[1]}" for col in hourly_region_model_stats.columns] 514 | 515 | # Reset the index to get a flat dataframe 516 | hourly_region_model_stats = hourly_region_model_stats.reset_index() 517 | 518 | # Rename inputTokens_count to request_count 519 | hourly_region_model_stats = hourly_region_model_stats.rename(columns={'inputTokens_count': 'request_count'}) 520 | 521 | # Format model IDs to be more readable 522 | hourly_region_model_stats['modelId'] = hourly_region_model_stats['modelId'].apply( 523 | lambda model: model.split('.')[-1] if '.' in model else model.split('/')[-1] 524 | ) 525 | 526 | # Add the hourly region-model stats to result 527 | result_parts.append(hourly_region_model_stats.to_string(index=False)) 528 | 529 | # Add summary statistics 530 | result_parts.append("\n=== Summary Statistics ===") 531 | 532 | # Total requests and tokens 533 | total_requests = hourly_stats['request_count'].sum() 534 | total_input_tokens = hourly_stats['inputTokens_sum'].sum() 535 | total_completion_tokens = hourly_stats['completionTokens_sum'].sum() 536 | total_tokens = hourly_stats['totalTokens_sum'].sum() 537 | 538 | result_parts.append(f"Total Requests: {total_requests:,}") 539 | result_parts.append(f"Total Input Tokens: {total_input_tokens:,}") 540 | result_parts.append(f"Total Completion Tokens: {total_completion_tokens:,}") 541 | result_parts.append(f"Total Tokens: {total_tokens:,}") 542 | 543 | # === REGION SUMMARY === 544 | result_parts.append("\n=== Region Summary ===") 545 | region_summary = df.groupby('region').agg({ 546 | 'inputTokens': ['count', 'sum'], 547 | 'completionTokens': ['sum'], 548 | 'totalTokens': ['sum'] 549 | }) 550 | 551 | # Flatten region summary columns 552 | region_summary.columns = [f"{col[0]}_{col[1]}" for col in region_summary.columns] 553 | region_summary = region_summary.reset_index() 554 | region_summary = region_summary.rename(columns={'inputTokens_count': 'request_count'}) 555 | 556 | result_parts.append(region_summary.to_string(index=False)) 557 | 558 | # === MODEL SUMMARY === 559 | result_parts.append("\n=== Model Summary ===") 560 | model_summary = df.groupby('modelId').agg({ 561 | 'inputTokens': ['count', 'sum'], 562 | 'completionTokens': ['sum'], 563 | 'totalTokens': ['sum'] 564 | }) 565 | 566 | # Flatten model summary columns 567 | model_summary.columns = [f"{col[0]}_{col[1]}" for col in model_summary.columns] 568 | model_summary = model_summary.reset_index() 569 | model_summary = model_summary.rename(columns={'inputTokens_count': 'request_count'}) 570 | 571 | # Format model IDs to be more readable 572 | model_summary['modelId'] = model_summary['modelId'].apply( 573 | lambda model: model.split('.')[-1] if '.' in model else model.split('/')[-1] 574 | ) 575 | 576 | result_parts.append(model_summary.to_string(index=False)) 577 | 578 | # === USER SUMMARY === 579 | if 'userId' in df.columns: 580 | result_parts.append("\n=== User Summary ===") 581 | user_summary = df.groupby('userId').agg({ 582 | 'inputTokens': ['count', 'sum'], 583 | 'completionTokens': ['sum'], 584 | 'totalTokens': ['sum'] 585 | }) 586 | 587 | # Flatten user summary columns 588 | user_summary.columns = [f"{col[0]}_{col[1]}" for col in user_summary.columns] 589 | user_summary = user_summary.reset_index() 590 | user_summary = user_summary.rename(columns={'inputTokens_count': 'request_count'}) 591 | 592 | result_parts.append(user_summary.to_string(index=False)) 593 | 594 | # === HOURLY REGION -> USER -> MODEL DETAILED SUMMARY === 595 | if 'userId' in df.columns: 596 | result_parts.append("\n=== Hourly Region -> User -> Model Detailed Summary ===") 597 | hourly_region_user_model_summary = df.groupby(['datetime', 'region', 'userId', 'modelId']).agg({ 598 | 'inputTokens': ['count', 'sum', 'mean'], 599 | 'completionTokens': ['sum', 'mean'], 600 | 'totalTokens': ['sum', 'mean'] 601 | }) 602 | 603 | # Flatten columns 604 | hourly_region_user_model_summary.columns = [f"{col[0]}_{col[1]}" for col in hourly_region_user_model_summary.columns] 605 | hourly_region_user_model_summary = hourly_region_user_model_summary.reset_index() 606 | hourly_region_user_model_summary = hourly_region_user_model_summary.rename(columns={'inputTokens_count': 'request_count'}) 607 | 608 | # Format model IDs to be more readable 609 | hourly_region_user_model_summary['modelId'] = hourly_region_user_model_summary['modelId'].apply( 610 | lambda model: model.split('.')[-1] if '.' in model else model.split('/')[-1] 611 | ) 612 | 613 | result_parts.append(hourly_region_user_model_summary.to_string(index=False)) 614 | 615 | # === HOURLY USAGE PATTERN ANALYSIS === 616 | result_parts.append("\n=== Hourly Usage Pattern Analysis ===") 617 | 618 | # Group by hour of day (ignoring date) to see hourly patterns 619 | hour_pattern = df.groupby(df['timestamp'].dt.hour).agg({ 620 | 'inputTokens': ['count', 'sum'], 621 | 'totalTokens': ['sum'] 622 | }) 623 | 624 | # Flatten hour pattern columns 625 | hour_pattern.columns = [f"{col[0]}_{col[1]}" for col in hour_pattern.columns] 626 | hour_pattern = hour_pattern.reset_index() 627 | hour_pattern = hour_pattern.rename(columns={ 628 | 'timestamp': 'hour_of_day', 629 | 'inputTokens_count': 'request_count' 630 | }) 631 | 632 | # Format the hour to be more readable 633 | hour_pattern['hour_of_day'] = hour_pattern['hour_of_day'].apply( 634 | lambda hour: f"{hour:02d}:00 - {hour:02d}:59" 635 | ) 636 | 637 | result_parts.append(hour_pattern.to_string(index=False)) 638 | 639 | # Combine all parts into a single string 640 | result = "\n".join(result_parts) 641 | 642 | return result 643 | 644 | @mcp.tool() 645 | async def get_ec2_spend_last_day(params: EC2Params) -> Dict[str, Any]: 646 | """ 647 | Retrieve EC2 spend for the last day using standard AWS Cost Explorer API. 648 | 649 | Returns: 650 | Dict[str, Any]: The raw response from the AWS Cost Explorer API, or None if an error occurs. 651 | """ 652 | print(f"get_ec2_spend_last_day, params={params}") 653 | # Initialize the Cost Explorer client 654 | ce_client = get_aws_service_boto3_client("ce", params.aws_account_id, params.region) 655 | 656 | 657 | # Calculate the time period - last day 658 | end_date = datetime.now().strftime('%Y-%m-%d') 659 | start_date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') 660 | 661 | try: 662 | # Make the API call using get_cost_and_usage (standard API) 663 | response = ce_client.get_cost_and_usage( 664 | TimePeriod={ 665 | 'Start': start_date, 666 | 'End': end_date 667 | }, 668 | Granularity='DAILY', 669 | Filter={ 670 | 'Dimensions': { 671 | 'Key': 'SERVICE', 672 | 'Values': [ 673 | 'Amazon Elastic Compute Cloud - Compute' 674 | ] 675 | } 676 | }, 677 | Metrics=[ 678 | 'UnblendedCost', 679 | 'UsageQuantity' 680 | ], 681 | GroupBy=[ 682 | { 683 | 'Type': 'DIMENSION', 684 | 'Key': 'INSTANCE_TYPE' 685 | } 686 | ] 687 | ) 688 | 689 | # Process and print the results 690 | print(f"EC2 Spend from {start_date} to {end_date}:") 691 | print("-" * 50) 692 | 693 | total_cost = 0.0 694 | 695 | if 'ResultsByTime' in response and response['ResultsByTime']: 696 | time_period_data = response['ResultsByTime'][0] 697 | 698 | if 'Groups' in time_period_data: 699 | for group in time_period_data['Groups']: 700 | instance_type = group['Keys'][0] 701 | cost = float(group['Metrics']['UnblendedCost']['Amount']) 702 | currency = group['Metrics']['UnblendedCost']['Unit'] 703 | usage = float(group['Metrics']['UsageQuantity']['Amount']) 704 | 705 | print(f"Instance Type: {instance_type}") 706 | print(f"Cost: {cost:.4f} {currency}") 707 | print(f"Usage: {usage:.2f}") 708 | print("-" * 30) 709 | 710 | total_cost += cost 711 | 712 | # If no instance-level breakdown, show total 713 | if not time_period_data.get('Groups'): 714 | if 'Total' in time_period_data: 715 | total = time_period_data['Total'] 716 | cost = float(total['UnblendedCost']['Amount']) 717 | currency = total['UnblendedCost']['Unit'] 718 | print(f"Total EC2 Cost: {cost:.4f} {currency}") 719 | else: 720 | print("No EC2 costs found for this period") 721 | else: 722 | print(f"Total EC2 Cost: {total_cost:.4f} {currency if 'currency' in locals() else 'USD'}") 723 | 724 | # Check if results are estimated 725 | if 'Estimated' in time_period_data: 726 | print(f"Note: These results are {'estimated' if time_period_data['Estimated'] else 'final'}") 727 | 728 | return response 729 | 730 | except Exception as e: 731 | print(f"Error retrieving EC2 cost data: {str(e)}") 732 | return None 733 | 734 | 735 | @mcp.tool() 736 | async def get_detailed_breakdown_by_day(params: EC2Params) -> str: #Dict[str, Any]: 737 | """ 738 | Retrieve daily spend breakdown by region, service, and instance type. 739 | 740 | Args: 741 | params: Parameters specifying the number of days to look back 742 | 743 | Returns: 744 | Dict[str, Any]: A tuple containing: 745 | - A nested dictionary with cost data organized by date, region, and service 746 | - A string containing the formatted output report 747 | or (None, error_message) if an error occurs. 748 | """ 749 | print(f"get_detailed_breakdown_by_day, params={params}") 750 | # Initialize the Cost Explorer client 751 | ce_client = get_aws_service_boto3_client("ce", params.aws_account_id, params.region) 752 | 753 | # Get the days parameter 754 | days = params.days 755 | 756 | # Calculate the time period 757 | end_date = datetime.now().strftime('%Y-%m-%d') 758 | start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') 759 | 760 | # Initialize output buffer 761 | output_buffer = [] 762 | 763 | try: 764 | output_buffer.append(f"\nDetailed Cost Breakdown by Region, Service, and Instance Type ({days} days):") 765 | output_buffer.append("-" * 75) 766 | 767 | # First get the daily costs by region and service 768 | response = ce_client.get_cost_and_usage( 769 | TimePeriod={ 770 | 'Start': start_date, 771 | 'End': end_date 772 | }, 773 | Granularity='DAILY', 774 | Metrics=['UnblendedCost'], 775 | GroupBy=[ 776 | { 777 | 'Type': 'DIMENSION', 778 | 'Key': 'REGION' 779 | }, 780 | { 781 | 'Type': 'DIMENSION', 782 | 'Key': 'SERVICE' 783 | } 784 | ] 785 | ) 786 | 787 | # Create data structure to hold the results 788 | all_data = defaultdict(lambda: defaultdict(lambda: defaultdict(float))) 789 | 790 | # Process the results 791 | for time_data in response['ResultsByTime']: 792 | date = time_data['TimePeriod']['Start'] 793 | 794 | output_buffer.append(f"\nDate: {date}") 795 | output_buffer.append("=" * 50) 796 | 797 | if 'Groups' in time_data and time_data['Groups']: 798 | # Create data structure for this date 799 | region_services = defaultdict(lambda: defaultdict(float)) 800 | 801 | # Process groups 802 | for group in time_data['Groups']: 803 | region, service = group['Keys'] 804 | cost = float(group['Metrics']['UnblendedCost']['Amount']) 805 | currency = group['Metrics']['UnblendedCost']['Unit'] 806 | 807 | region_services[region][service] = cost 808 | all_data[date][region][service] = cost 809 | 810 | # Add the results for this date to the buffer 811 | for region in sorted(region_services.keys()): 812 | output_buffer.append(f"\nRegion: {region}") 813 | output_buffer.append("-" * 40) 814 | 815 | # Create a DataFrame for this region's services 816 | services_df = pd.DataFrame({ 817 | 'Service': list(region_services[region].keys()), 818 | 'Cost': list(region_services[region].values()) 819 | }) 820 | 821 | # Sort by cost descending 822 | services_df = services_df.sort_values('Cost', ascending=False) 823 | 824 | # Get top services by cost 825 | top_services = services_df.head(5) 826 | 827 | # Add region's services table to buffer 828 | output_buffer.append(tabulate(top_services.round(2), headers='keys', tablefmt='pretty', showindex=False)) 829 | 830 | # If there are more services, indicate the total for other services 831 | if len(services_df) > 5: 832 | other_cost = services_df.iloc[5:]['Cost'].sum() 833 | output_buffer.append(f"... and {len(services_df) - 5} more services totaling {other_cost:.2f} {currency}") 834 | 835 | # For EC2, get instance type breakdown 836 | if any(s.startswith('Amazon Elastic Compute') for s in region_services[region].keys()): 837 | try: 838 | instance_response = get_instance_type_breakdown( 839 | ce_client, 840 | date, 841 | region, 842 | 'Amazon Elastic Compute Cloud - Compute', 843 | 'INSTANCE_TYPE' 844 | ) 845 | 846 | if instance_response: 847 | output_buffer.append("\n EC2 Instance Type Breakdown:") 848 | output_buffer.append(" " + "-" * 38) 849 | 850 | # Get table with indentation 851 | instance_table = tabulate(instance_response.round(2), headers='keys', tablefmt='pretty', showindex=False) 852 | for line in instance_table.split('\n'): 853 | output_buffer.append(f" {line}") 854 | 855 | except Exception as e: 856 | output_buffer.append(f" Note: Could not retrieve EC2 instance type breakdown: {str(e)}") 857 | 858 | # For SageMaker, get instance type breakdown 859 | if any(s == 'Amazon SageMaker' for s in region_services[region].keys()): 860 | try: 861 | sagemaker_instance_response = get_instance_type_breakdown( 862 | ce_client, 863 | date, 864 | region, 865 | 'Amazon SageMaker', 866 | 'INSTANCE_TYPE' 867 | ) 868 | 869 | if sagemaker_instance_response is not None and not sagemaker_instance_response.empty: 870 | output_buffer.append("\n SageMaker Instance Type Breakdown:") 871 | output_buffer.append(" " + "-" * 38) 872 | 873 | # Get table with indentation 874 | sagemaker_table = tabulate(sagemaker_instance_response.round(2), headers='keys', tablefmt='pretty', showindex=False) 875 | for line in sagemaker_table.split('\n'): 876 | output_buffer.append(f" {line}") 877 | 878 | # Also try to get usage type breakdown for SageMaker (notebooks, endpoints, etc.) 879 | sagemaker_usage_response = get_instance_type_breakdown( 880 | ce_client, 881 | date, 882 | region, 883 | 'Amazon SageMaker', 884 | 'USAGE_TYPE' 885 | ) 886 | 887 | if sagemaker_usage_response is not None and not sagemaker_usage_response.empty: 888 | output_buffer.append("\n SageMaker Usage Type Breakdown:") 889 | output_buffer.append(" " + "-" * 38) 890 | 891 | # Get table with indentation 892 | usage_table = tabulate(sagemaker_usage_response.round(2), headers='keys', tablefmt='pretty', showindex=False) 893 | for line in usage_table.split('\n'): 894 | output_buffer.append(f" {line}") 895 | 896 | except Exception as e: 897 | output_buffer.append(f" Note: Could not retrieve SageMaker breakdown: {str(e)}") 898 | else: 899 | output_buffer.append("No data found for this date") 900 | 901 | output_buffer.append("\n" + "-" * 75) 902 | 903 | # Join the buffer into a single string 904 | formatted_output = "\n".join(output_buffer) 905 | 906 | # Return both the raw data and the formatted output 907 | #return {"data": all_data, "formatted_output": formatted_output} 908 | return formatted_output 909 | 910 | except Exception as e: 911 | error_message = f"Error retrieving detailed breakdown: {str(e)}" 912 | #return {"data": None, "formatted_output": error_message} 913 | return error_message 914 | 915 | def get_instance_type_breakdown(ce_client, date, region, service, dimension_key): 916 | """ 917 | Helper function to get instance type or usage type breakdown for a specific service. 918 | 919 | Args: 920 | ce_client: The Cost Explorer client 921 | date: The date to query 922 | region: The AWS region 923 | service: The AWS service name 924 | dimension_key: The dimension to group by (e.g., 'INSTANCE_TYPE' or 'USAGE_TYPE') 925 | 926 | Returns: 927 | DataFrame containing the breakdown or None if no data 928 | """ 929 | tomorrow = (datetime.strptime(date, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d') 930 | 931 | instance_response = ce_client.get_cost_and_usage( 932 | TimePeriod={ 933 | 'Start': date, 934 | 'End': tomorrow 935 | }, 936 | Granularity='DAILY', 937 | Filter={ 938 | 'And': [ 939 | { 940 | 'Dimensions': { 941 | 'Key': 'REGION', 942 | 'Values': [region] 943 | } 944 | }, 945 | { 946 | 'Dimensions': { 947 | 'Key': 'SERVICE', 948 | 'Values': [service] 949 | } 950 | } 951 | ] 952 | }, 953 | Metrics=['UnblendedCost'], 954 | GroupBy=[ 955 | { 956 | 'Type': 'DIMENSION', 957 | 'Key': dimension_key 958 | } 959 | ] 960 | ) 961 | 962 | if ('ResultsByTime' in instance_response and 963 | instance_response['ResultsByTime'] and 964 | 'Groups' in instance_response['ResultsByTime'][0] and 965 | instance_response['ResultsByTime'][0]['Groups']): 966 | 967 | instance_data = instance_response['ResultsByTime'][0] 968 | instance_costs = [] 969 | 970 | for instance_group in instance_data['Groups']: 971 | type_value = instance_group['Keys'][0] 972 | cost_value = float(instance_group['Metrics']['UnblendedCost']['Amount']) 973 | 974 | # Add a better label for the dimension used 975 | column_name = 'Instance Type' if dimension_key == 'INSTANCE_TYPE' else 'Usage Type' 976 | 977 | instance_costs.append({ 978 | column_name: type_value, 979 | 'Cost': cost_value 980 | }) 981 | 982 | # Create DataFrame and sort by cost 983 | result_df = pd.DataFrame(instance_costs) 984 | if not result_df.empty: 985 | result_df = result_df.sort_values('Cost', ascending=False) 986 | return result_df 987 | 988 | return None 989 | 990 | @mcp.resource("config://app") 991 | def get_config() -> str: 992 | """Static configuration data""" 993 | return "App configuration here" 994 | 995 | def main(): 996 | # Run the server with the specified transport 997 | mcp.run(transport=os.environ.get('MCP_TRANSPORT', 'stdio')) 998 | 999 | if __name__ == "__main__": 1000 | main() 1001 | --------------------------------------------------------------------------------