Browse Source

feat: add MCP server for OpenCost allocation and asset queries (#3386)

Signed-off-by: sneax <paladesh600@gmail.com>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
segfault_bits 6 months ago
parent
commit
37e08c6365

+ 214 - 2
README.md

@@ -29,9 +29,17 @@ To see the full functionality of OpenCost you can view [OpenCost features](https
 
 
 ## Getting Started
 ## Getting Started
 
 
-You can deploy OpenCost on any Kubernetes 1.20+ cluster in a matter of minutes, if not seconds!
+OpenCost is now installed and managed via the official Helm chart only.
 
 
-Visit the full documentation for [recommended installation options](https://www.opencost.io/docs/installation/install).
+Quick install on any Kubernetes 1.20+ cluster:
+
+```bash
+helm repo add opencost https://opencost.github.io/opencost-helm-chart
+helm repo update
+helm install opencost opencost/opencost
+```
+
+Note: The standalone Kubernetes manifest files have been removed. Please use Helm for all installations and upgrades. See the [Helm installation docs](https://www.opencost.io/docs/installation/install) for details and configuration.
 
 
 > **Note for sharded Prometheus users:**
 > **Note for sharded Prometheus users:**
 > If you run Prometheus in a sharded (HA) setup, set `PROMETHEUS_SERVER_ENDPOINT` to a global query endpoint (e.g., Thanos Query, Cortex, or Mimir). Pointing to a single Prometheus pod may result in incomplete or intermittent export results. See the [Prometheus integration docs](https://www.opencost.io/docs/installation/prometheus) for details.
 > If you run Prometheus in a sharded (HA) setup, set `PROMETHEUS_SERVER_ENDPOINT` to a global query endpoint (e.g., Thanos Query, Cortex, or Mimir). Pointing to a single Prometheus pod may result in incomplete or intermittent export results. See the [Prometheus integration docs](https://www.opencost.io/docs/installation/prometheus) for details.
@@ -43,6 +51,210 @@ Visit the full documentation for [recommended installation options](https://www.
 - [Prometheus Metrics](https://www.opencost.io/docs/integrations/prometheus)
 - [Prometheus Metrics](https://www.opencost.io/docs/integrations/prometheus)
 - [User Interface](https://www.opencost.io/docs/installation/ui)
 - [User Interface](https://www.opencost.io/docs/installation/ui)
 
 
+## MCP Server
+
+The OpenCost MCP (Model Context Protocol) server provides AI agents with access to cost allocation and asset data through a standardized interface. The MCP server is **enabled by default** in all OpenCost deployments, runs on port 8081, and is **built into the Helm chart** for easy production deployment. Users have full control to disable it or configure custom ports and settings.
+
+### Features
+
+- **Enabled by Default**: MCP server starts automatically with OpenCost
+- **Full User Control**: Easy to disable or configure port and settings
+- **Allocation Queries**: Retrieve cost allocation data with filtering and aggregation
+- **Asset Queries**: Access detailed asset information including nodes, disks, load balancers, and more
+- **Cloud Cost Queries**: Query cloud cost data with provider, service, and region filtering
+- **HTTP Transport**: Uses HTTP for reliable communication with MCP clients
+- **Zero Configuration**: Works out of the box with default OpenCost deployment
+- **Helm Integration**: Built into the official Helm chart for production deployments
+
+### Quick Start
+
+#### Using Tilt (Development)
+```bash
+# Clone and start OpenCost with MCP server
+git clone https://github.com/opencost/opencost.git
+cd opencost
+tilt up
+```
+
+Tilt configuration notes (cloud costs):
+
+OpenCost's Tilt values (`tilt-values.yaml`) include extra environment variables to enable Cloud Cost ingestion in dev:
+
+```yaml
+# tilt-values.yaml (excerpt)
+opencost:
+  exporter:
+    extraEnv:
+      CLOUD_COST_ENABLED: "true"
+      CLOUD_COST_CONFIG_PATH: "/var/cloud-integration/cloud-integration.json"
+```
+
+- Set `CLOUD_COST_ENABLED` to "true" to turn on cloud cost ingestion.
+- Point `CLOUD_COST_CONFIG_PATH` to the mounted cloud integration file used by Tilt (e.g., `/var/cloud-integration/cloud-integration.json`).
+- Adjust other values in `tilt-values.yaml` as needed during development.
+
+#### Using Helm (Production)
+```bash
+# Add the OpenCost Helm repository
+helm repo add opencost https://opencost.github.io/opencost-helm-chart
+helm repo update
+
+# Deploy OpenCost with MCP server (enabled by default)
+helm install opencost opencost/opencost
+
+# Access MCP server via port forwarding (example)
+kubectl port-forward svc/opencost 8081:8081
+```
+
+The MCP server is **enabled by default** in the Helm chart. For custom configuration:
+
+```bash
+# Deploy with MCP server disabled
+helm install opencost opencost/opencost \
+  --set opencost.mcp.enabled=false
+
+# Deploy with custom MCP port
+helm install opencost opencost/opencost \
+  --set opencost.mcp.port=9091
+
+# Deploy with debug logging
+helm install opencost opencost/opencost \
+  --set opencost.mcp.extraEnv.MCP_LOG_LEVEL=debug
+```
+
+#### Configuration Summary
+
+| Configuration | Command | Description |
+|---------------|---------|-------------|
+| **Default** | `helm install opencost opencost/opencost` | MCP enabled on port 8081 |
+| **Disable** | `--set opencost.mcp.enabled=false` | Completely disable MCP server |
+| **Custom Port** | `--set opencost.mcp.port=9091` | Use different port |
+| **Debug Mode** | `--set opencost.mcp.extraEnv.MCP_LOG_LEVEL=debug` | Enable debug logging |
+
+### MCP Client Configuration
+
+Configure your MCP client (e.g., Cursor) to connect to the OpenCost MCP server:
+
+**Default configuration (port 8081):**
+```json
+{
+  "mcpServers": {
+    "opencost": {
+      "type": "http",
+      "url": "http://localhost:8081"
+    }
+  }
+}
+```
+
+**Custom port configuration:**
+```json
+{
+  "mcpServers": {
+    "opencost": {
+      "type": "http",
+      "url": "http://localhost:9091"
+    }
+  }
+}
+```
+
+**For Kubernetes deployments:**
+```json
+{
+  "mcpServers": {
+    "opencost": {
+      "type": "http",
+      "url": "http://opencost.opencost.svc.cluster.local:8081"
+    }
+  }
+}
+```
+
+**For external access (with LoadBalancer/Ingress):**
+```json
+{
+  "mcpServers": {
+    "opencost": {
+      "type": "http",
+      "url": "http://your-opencost-domain.com:8081"
+    }
+  }
+}
+```
+
+### Available MCP Tools
+
+The MCP server provides these tools for AI agents:
+
+#### `get_allocation_costs`
+Retrieve cost allocation data with filtering and aggregation.
+
+**Parameters:**
+- `window` (required): Time window (e.g., "7d", "1h", "30m")
+- `aggregate` (optional): Aggregation properties (e.g., "namespace", "pod", "node")
+- `step` (optional): Resolution step size
+- `accumulate` (optional): Whether to accumulate over time
+- `share_idle` (optional): Whether to share idle costs
+- `include_idle` (optional): Whether to include idle resources
+
+#### `get_asset_costs`
+Retrieve asset cost data including nodes, disks, load balancers, and more.
+
+**Parameters:**
+- `window` (required): Time window (e.g., "7d", "1h", "30m")
+
+#### `get_cloud_costs`
+Retrieve cloud cost data with provider, service, and region filtering.
+
+**Parameters:**
+- `window` (required): Time window (e.g., "7d", "1h", "30m")
+- `aggregate` (optional): Aggregation properties (e.g., "provider", "service", "region")
+- `accumulate` (optional): Time accumulation ("day", "week", "month")
+- `provider` (optional): Filter by cloud provider (e.g., "aws", "gcp", "azure")
+- `service` (optional): Filter by service (e.g., "ec2", "compute", "s3")
+- `category` (optional): Filter by category (e.g., "compute", "storage", "network")
+- `region` (optional): Filter by region (e.g., "us-west-1", "us-central1")
+- `accountID` (optional): Filter by account ID
+
+### Supported Asset Types
+
+- **Node**: Compute instances with CPU, RAM, GPU details
+- **Disk**: Storage volumes with usage and cost breakdown
+- **LoadBalancer**: Load balancer instances with IP and private status
+- **Network**: Network-related costs and usage
+- **Cloud**: Cloud service costs with credit information
+- **ClusterManagement**: Kubernetes cluster management costs
+
+### Example Usage
+
+Once configured, AI agents can query cost data like:
+
+```javascript
+// Get cost allocation for the last 7 days
+const allocation = await mcpClient.callTool('get_allocation_costs', {
+  window: '7d',
+  aggregate: 'namespace,node'
+});
+
+// Get asset costs for the last 24 hours
+const assets = await mcpClient.callTool('get_asset_costs', {
+  window: '1d'
+});
+
+// Get cloud costs for AWS EC2 in us-west-1
+const cloudCosts = await mcpClient.callTool('get_cloud_costs', {
+  window: '7d',
+  aggregate: 'service',
+  provider: 'aws',
+  service: 'ec2',
+  accumulate: 'day',
+  filter: 'regionID:"us-west-1"'
+});
+```
+
+For detailed setup instructions and advanced configuration, see the [Helm chart documentation](https://github.com/opencost/opencost-helm-chart/blob/main/charts/opencost/README.md#mcp-server).
+
 ## Contributing
 ## Contributing
 
 
 We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source and contributing changes.
 We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source and contributing changes.

+ 1 - 0
Tiltfile.opencost

@@ -147,6 +147,7 @@ def run_opencost(options):
         options['port_costmodel']+':9003',
         options['port_costmodel']+':9003',
         options['port_ui']+':9090',
         options['port_ui']+':9090',
         options['port_debug']+':40000',
         options['port_debug']+':40000',
+        '8081:8081',  # MCP server port
     ]
     ]
     k8s_resource(workload='opencost', port_forwards=port_forwards)
     k8s_resource(workload='opencost', port_forwards=port_forwards)
 
 

+ 8 - 0
go.mod

@@ -31,6 +31,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/sts v1.33.17
 	github.com/aws/aws-sdk-go-v2/service/sts v1.33.17
 	github.com/aws/smithy-go v1.23.0
 	github.com/aws/smithy-go v1.23.0
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
+	github.com/go-playground/validator/v10 v10.27.0
 	github.com/google/martian v2.1.0+incompatible
 	github.com/google/martian v2.1.0+incompatible
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/go-hclog v1.6.3
 	github.com/hashicorp/go-hclog v1.6.3
@@ -39,6 +40,7 @@ require (
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/kubecost/events v0.0.8
 	github.com/kubecost/events v0.0.8
 	github.com/microcosm-cc/bluemonday v1.0.23
 	github.com/microcosm-cc/bluemonday v1.0.23
+	github.com/modelcontextprotocol/go-sdk v1.0.0
 	github.com/opencost/opencost/core v0.0.0-20250521155634-81d2b597d1bc
 	github.com/opencost/opencost/core v0.0.0-20250521155634-81d2b597d1bc
 	github.com/opencost/opencost/modules/collector-source v0.0.0-00010101000000-000000000000
 	github.com/opencost/opencost/modules/collector-source v0.0.0-00010101000000-000000000000
 	github.com/opencost/opencost/modules/prometheus-source v0.0.0-00010101000000-000000000000
 	github.com/opencost/opencost/modules/prometheus-source v0.0.0-00010101000000-000000000000
@@ -74,9 +76,14 @@ require (
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/gofrs/flock v0.8.1 // indirect
 	github.com/gofrs/flock v0.8.1 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/minio/crc64nvme v1.0.1 // indirect
 	github.com/minio/crc64nvme v1.0.1 // indirect
 	github.com/minio/minio-go/v7 v7.0.88 // indirect
 	github.com/minio/minio-go/v7 v7.0.88 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
@@ -85,6 +92,7 @@ require (
 	github.com/sony/gobreaker v0.5.0 // indirect
 	github.com/sony/gobreaker v0.5.0 // indirect
 	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
 	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
+	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	github.com/zeebo/errs v1.4.0 // indirect
 	github.com/zeebo/errs v1.4.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect

+ 18 - 0
go.sum

@@ -237,6 +237,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
 github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
 github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
 github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -258,6 +260,14 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
 github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
 github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -328,6 +338,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -443,6 +455,8 @@ github.com/kubecost/events v0.0.8 h1:FEglMSOGkjiSZT2FnSYM99s2M4DMiBOgHVheM7Vnurs
 github.com/kubecost/events v0.0.8/go.mod h1:PXnE7CSZs3OulOLcB8baQENploBp4NM7ERZVBCqNi4A=
 github.com/kubecost/events v0.0.8/go.mod h1:PXnE7CSZs3OulOLcB8baQENploBp4NM7ERZVBCqNi4A=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@@ -479,6 +493,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
+github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -594,6 +610,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
 github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
 github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

+ 0 - 1
kubernetes/README.md

@@ -1 +0,0 @@
-<https://www.opencost.io/docs/>

+ 0 - 1
kubernetes/exporter/README.md

@@ -1 +0,0 @@
-<https://www.opencost.io/docs/>

+ 0 - 192
kubernetes/exporter/opencost-exporter.yaml

@@ -1,192 +0,0 @@
----
-
-# The namespace opencost will run in
-apiVersion: v1
-kind: Namespace
-metadata:
-    name: opencost-exporter
----
-
-# Service account for permissions
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: opencost
----
-
-# Cluster role giving opencost to get, list, watch required resources
-# No write permissions are required
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: opencost
-rules:
-  - apiGroups:
-      - ''
-    resources:
-      - configmaps
-      - deployments
-      - nodes
-      - pods
-      - services
-      - resourcequotas
-      - replicationcontrollers
-      - limitranges
-      - persistentvolumeclaims
-      - persistentvolumes
-      - namespaces
-      - endpoints
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - extensions
-    resources:
-      - daemonsets
-      - deployments
-      - replicasets
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - apps
-    resources:
-      - statefulsets
-      - deployments
-      - daemonsets
-      - replicasets
-    verbs:
-      - list
-      - watch
-  - apiGroups:
-      - batch
-    resources:
-      - cronjobs
-      - jobs
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - autoscaling
-    resources:
-      - horizontalpodautoscalers
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - policy
-    resources:
-      - poddisruptionbudgets
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - storage.k8s.io
-    resources:
-      - storageclasses
-    verbs:
-      - get
-      - list
-      - watch
-
----
-
-# Bind the role to the service account
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: opencost
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: opencost
-subjects:
-  - kind: ServiceAccount
-    name: opencost
-    namespace: opencost-exporter
----
-
-# Create a deployment for a single cost model pod
-#
-# See environment variables if you would like to add a Prometheus for
-# cost model to read from for full functionality.
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: opencost
-  labels:
-    app: opencost
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: opencost
-  strategy:
-    rollingUpdate:
-      maxSurge: 1
-      maxUnavailable: 1
-    type: RollingUpdate
-  template:
-    metadata:
-      labels:
-        app: opencost
-    spec:
-      restartPolicy: Always
-      serviceAccountName: opencost
-      containers:
-        - image: quay.io/kubecost1/kubecost-cost-model:latest
-          name: opencost
-          resources:
-            requests:
-              cpu: "10m"
-              memory: "55M"
-            limits:
-              cpu: "999m"
-              memory: "1G"
-          env:
-            - name: PROMETHEUS_SERVER_ENDPOINT
-              value: "http://prometheus-server.prometheus-system.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
-            - name: CLOUD_PROVIDER_API_KEY
-              value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
-            - name: CLUSTER_ID
-              value: "cluster-one" # Default cluster ID to use if cluster_id is not set in Prometheus metrics.
-            - name: EXPORT_CSV_FILE
-              value: "s3://path/to/csv"
-            - name: AWS_ACCESS_KEY_ID  
-              value: "XXXXXXXXXXXXXXX" ## AWS Access KeyID
-            - name: AWS_SECRET_ACCESS_KEY
-              value: "XXXXXXXXXXXXXXX" ## AWS Secret Access Key
-            - name: AWS_REGION
-              value: "us-west-2" ## AWS Region where bucket is hosted
-          imagePullPolicy: Always
-          volumeMounts:
-          - name: tmp-volume
-            mountPath: /tmp
-      volumes:
-      - name: tmp-volume
-        emptyDir: {}
----
-
-# Expose the cost model with a service
-#
-# Without a Prometheus endpoint configured in the deployment,
-# only opencost/metrics will have useful data as it is intended
-# to be used as just an exporter.
-kind: Service
-apiVersion: v1
-metadata:
-  name: opencost
-spec:
-  selector:
-    app: opencost
-  type: ClusterIP
-  ports:
-    - name: opencost
-      port: 9003
-      targetPort: 9003
----

+ 0 - 203
kubernetes/opencost.yaml

@@ -1,203 +0,0 @@
-# <https://www.opencost.io/docs/>
----
-
-# The namespace OpenCost will run in
-apiVersion: v1
-kind: Namespace
-metadata:
-    name: opencost
----
-
-# Service account for permissions
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: opencost
-  namespace: opencost
----
-
-# Cluster role giving OpenCost to get, list, watch required resources
-# No write permissions are required
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: opencost
-rules:
-  - apiGroups:
-      - ''
-    resources:
-      - configmaps
-      - deployments
-      - nodes
-      - pods
-      - services
-      - resourcequotas
-      - replicationcontrollers
-      - limitranges
-      - persistentvolumeclaims
-      - persistentvolumes
-      - namespaces
-      - endpoints
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - extensions
-    resources:
-      - daemonsets
-      - deployments
-      - replicasets
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - apps
-    resources:
-      - statefulsets
-      - deployments
-      - daemonsets
-      - replicasets
-    verbs:
-      - list
-      - watch
-  - apiGroups:
-      - batch
-    resources:
-      - cronjobs
-      - jobs
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - autoscaling
-    resources:
-      - horizontalpodautoscalers
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - policy
-    resources:
-      - poddisruptionbudgets
-    verbs:
-      - get
-      - list
-      - watch
-  - apiGroups:
-      - storage.k8s.io
-    resources:
-      - storageclasses
-    verbs:
-      - get
-      - list
-      - watch
-
----
-
-# Bind the role to the service account
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: opencost
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: opencost
-subjects:
-  - kind: ServiceAccount
-    name: opencost
-    namespace: opencost
----
-
-# Create a deployment for a single cost model pod
-#
-# See environment variables if you would like to add a Prometheus for
-# cost model to read from for full functionality.
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: opencost
-  namespace: opencost
-  labels:
-    app: opencost
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: opencost
-  strategy:
-    rollingUpdate:
-      maxSurge: 1
-      maxUnavailable: 1
-    type: RollingUpdate
-  template:
-    metadata:
-      labels:
-        app: opencost
-    spec:
-      restartPolicy: Always
-      serviceAccountName: opencost
-      containers:
-        - image: ghcr.io/opencost/opencost:latest
-          name: opencost
-          resources:
-            requests:
-              cpu: "10m"
-              memory: "55M"
-            limits:
-              cpu: "999m"
-              memory: "1G"
-          env:
-            - name: PROMETHEUS_SERVER_ENDPOINT
-              value: "http://prometheus-server.prometheus-system.svc" # The endpoint should have the form http://<service-name>.<namespace-name>.svc
-            - name: CLOUD_PROVIDER_API_KEY
-              value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
-            - name: CLUSTER_ID
-              value: "cluster-one" # Default cluster ID to use if cluster_id is not set in Prometheus metrics.
-          imagePullPolicy: Always
-          securityContext:
-            allowPrivilegeEscalation: false
-            capabilities:
-              drop:
-                - ALL
-            privileged: false
-            readOnlyRootFilesystem: true
-            runAsUser: 1001
-        - image: ghcr.io/opencost/opencost-ui:latest
-          name: opencost-ui
-          resources:
-            requests:
-              cpu: "10m"
-              memory: "55M"
-            limits:
-              cpu: "999m"
-              memory: "1G"
-          imagePullPolicy: Always
----
-
-# Expose the cost model with a service
-#
-# Without a Prometheus endpoint configured in the deployment,
-# only opencost/metrics will have useful data as it is intended
-# to be used as only an exporter.
-kind: Service
-apiVersion: v1
-metadata:
-  name: opencost
-  namespace: opencost
-spec:
-  selector:
-    app: opencost
-  type: ClusterIP
-  ports:
-    - name: opencost
-      port: 9003
-      targetPort: 9003
-    - name: opencost-ui
-      port: 9090
-      targetPort: 9090
----

+ 0 - 12
kubernetes/prometheus/extraScrapeConfigs.yaml

@@ -1,12 +0,0 @@
-extraScrapeConfigs: |
-  - job_name: opencost
-    honor_labels: true
-    scrape_interval: 1m
-    scrape_timeout: 10s
-    metrics_path: /metrics
-    scheme: http
-    dns_sd_configs:
-    - names:
-      - opencost.opencost
-      type: 'A'
-      port: 9003

+ 13 - 0
pkg/cloudcost/ingestionmanager.go

@@ -120,6 +120,19 @@ func (im *IngestionManager) RebuildAll() {
 	wg.Wait()
 	wg.Wait()
 }
 }
 
 
+// GetIngestors returns a copy of the ingestors map
+func (im *IngestionManager) GetIngestors() map[string]*ingestor {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+
+	// Return a copy to avoid race conditions
+	copy := make(map[string]*ingestor)
+	for k, v := range im.ingestors {
+		copy[k] = v
+	}
+	return copy
+}
+
 func (im *IngestionManager) Rebuild(integrationKey string) error {
 func (im *IngestionManager) Rebuild(integrationKey string) error {
 	im.lock.Lock()
 	im.lock.Lock()
 	defer im.lock.Unlock()
 	defer im.lock.Unlock()

+ 8 - 0
pkg/cloudcost/pipelineservice.go

@@ -173,6 +173,14 @@ func (s *PipelineService) GetCloudCostRepairHandler() func(w http.ResponseWriter
 	}
 	}
 }
 }
 
 
+// GetCloudCostQuerier returns a querier that can query data from all cloud providers
+func (s *PipelineService) GetCloudCostQuerier() Querier {
+	if s.store == nil {
+		return nil
+	}
+	return NewRepositoryQuerier(s.store)
+}
+
 // GetCloudCostStatusHandler creates a handler from a http request which returns a list of the billing integration status
 // GetCloudCostStatusHandler creates a handler from a http request which returns a list of the billing integration status
 func (s *PipelineService) GetCloudCostStatusHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 func (s *PipelineService) GetCloudCostStatusHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	// If Reporting Service is nil, always return 501
 	// If Reporting Service is nil, always return 501

+ 3 - 0
pkg/cmd/costmodel/config.go

@@ -12,6 +12,7 @@ type Config struct {
 	CarbonEstimatesEnabled bool
 	CarbonEstimatesEnabled bool
 	CloudCostEnabled       bool
 	CloudCostEnabled       bool
 	CustomCostEnabled      bool
 	CustomCostEnabled      bool
+	MCPServerEnabled       bool
 }
 }
 
 
 func DefaultConfig() *Config {
 func DefaultConfig() *Config {
@@ -20,6 +21,7 @@ func DefaultConfig() *Config {
 		KubernetesEnabled:      env.IsKubernetesEnabled(),
 		KubernetesEnabled:      env.IsKubernetesEnabled(),
 		CarbonEstimatesEnabled: env.IsCarbonEstimatesEnabled(),
 		CarbonEstimatesEnabled: env.IsCarbonEstimatesEnabled(),
 		CloudCostEnabled:       env.IsCloudCostEnabled(),
 		CloudCostEnabled:       env.IsCloudCostEnabled(),
+		MCPServerEnabled:       env.IsMCPServerEnabled(),
 	}
 	}
 }
 }
 
 
@@ -28,4 +30,5 @@ func (c *Config) log() {
 	log.Infof("Carbon Estimates enabled: %t", c.CarbonEstimatesEnabled)
 	log.Infof("Carbon Estimates enabled: %t", c.CarbonEstimatesEnabled)
 	log.Infof("Cloud Costs enabled: %t", c.CloudCostEnabled)
 	log.Infof("Cloud Costs enabled: %t", c.CloudCostEnabled)
 	log.Infof("Custom Costs enabled: %t", c.CustomCostEnabled)
 	log.Infof("Custom Costs enabled: %t", c.CustomCostEnabled)
+	log.Infof("MCP Server enabled: %t", c.MCPServerEnabled)
 }
 }

+ 208 - 2
pkg/cmd/costmodel/costmodel.go

@@ -10,16 +10,19 @@ import (
 	"github.com/opencost/opencost/core/pkg/util/apiutil"
 	"github.com/opencost/opencost/core/pkg/util/apiutil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/provider"
 	"github.com/opencost/opencost/pkg/cloud/provider"
+	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/customcost"
 	"github.com/opencost/opencost/pkg/customcost"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/rs/cors"
 	"github.com/rs/cors"
 
 
+	mcp_sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 	"github.com/opencost/opencost/core/pkg/errors"
 	"github.com/opencost/opencost/core/pkg/errors"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/version"
 	"github.com/opencost/opencost/core/pkg/version"
 	"github.com/opencost/opencost/pkg/costmodel"
 	"github.com/opencost/opencost/pkg/costmodel"
 	"github.com/opencost/opencost/pkg/env"
 	"github.com/opencost/opencost/pkg/env"
 	"github.com/opencost/opencost/pkg/filemanager"
 	"github.com/opencost/opencost/pkg/filemanager"
+	opencost_mcp "github.com/opencost/opencost/pkg/mcp"
 	"github.com/opencost/opencost/pkg/metrics"
 	"github.com/opencost/opencost/pkg/metrics"
 )
 )
 
 
@@ -52,12 +55,13 @@ func Execute(conf *Config) error {
 		cp = a.CloudProvider
 		cp = a.CloudProvider
 	}
 	}
 
 
+	var cloudCostPipelineService *cloudcost.PipelineService
 	if conf.CloudCostEnabled {
 	if conf.CloudCostEnabled {
 		var providerConfig models.ProviderConfig
 		var providerConfig models.ProviderConfig
 		if cp != nil {
 		if cp != nil {
 			providerConfig = provider.ExtractConfigFromProviders(cp)
 			providerConfig = provider.ExtractConfigFromProviders(cp)
 		}
 		}
-		costmodel.InitializeCloudCost(router, providerConfig)
+		cloudCostPipelineService = costmodel.InitializeCloudCost(router, providerConfig)
 	}
 	}
 
 
 	var customCostPipelineService *customcost.PipelineService
 	var customCostPipelineService *customcost.PipelineService
@@ -69,6 +73,22 @@ func Execute(conf *Config) error {
 	// valid for CustomCostPipelineService to be nil
 	// valid for CustomCostPipelineService to be nil
 	router.GET("/customCost/status", customCostPipelineService.GetCustomCostStatusHandler())
 	router.GET("/customCost/status", customCostPipelineService.GetCustomCostStatusHandler())
 
 
+	// Initialize MCP Server if enabled and Kubernetes is available
+	if conf.MCPServerEnabled && a != nil {
+		// Get cloud cost querier if cloud costs are enabled
+		var cloudCostQuerier cloudcost.Querier
+		if conf.CloudCostEnabled && cloudCostPipelineService != nil {
+			cloudCostQuerier = cloudCostPipelineService.GetCloudCostQuerier()
+		}
+
+		err := StartMCPServer(context.Background(), a, cloudCostQuerier)
+		if err != nil {
+			log.Errorf("Failed to start MCP server: %v", err)
+		}
+	} else if conf.MCPServerEnabled {
+		log.Warnf("MCP Server is enabled but Kubernetes is not available. MCP server requires Kubernetes to function.")
+	}
+
 	apiutil.ApplyContainerDiagnosticEndpoints(router)
 	apiutil.ApplyContainerDiagnosticEndpoints(router)
 
 
 	rootMux := http.NewServeMux()
 	rootMux := http.NewServeMux()
@@ -99,7 +119,7 @@ func StartExportWorker(ctx context.Context, model costmodel.AllocationModel) err
 			select {
 			select {
 			case <-ctx.Done():
 			case <-ctx.Done():
 				return
 				return
-			case <-time.After(nextRunAt.Sub(time.Now())):
+			case <-time.After(time.Until(nextRunAt)):
 				err := costmodel.UpdateCSV(ctx, fm, model, env.GetExportCSVLabelsAll(), env.GetExportCSVLabelsList())
 				err := costmodel.UpdateCSV(ctx, fm, model, env.GetExportCSVLabelsAll(), env.GetExportCSVLabelsList())
 				if err != nil {
 				if err != nil {
 					// it's background worker, log error and carry on, maybe next time it will work
 					// it's background worker, log error and carry on, maybe next time it will work
@@ -114,3 +134,189 @@ func StartExportWorker(ctx context.Context, model costmodel.AllocationModel) err
 	}()
 	}()
 	return nil
 	return nil
 }
 }
+
+// StartMCPServer starts the MCP server as a background service
+func StartMCPServer(ctx context.Context, accesses *costmodel.Accesses, cloudCostQuerier cloudcost.Querier) error {
+	log.Info("Initializing MCP server...")
+
+	// Create MCP server using existing OpenCost dependencies
+	mcpServer := opencost_mcp.NewMCPServer(accesses.Model, accesses.CloudProvider, cloudCostQuerier)
+
+	// Create MCP SDK server
+	sdkServer := mcp_sdk.NewServer(&mcp_sdk.Implementation{
+		Name:    "opencost-mcp-server",
+		Version: version.Version,
+	}, nil)
+
+	// Define tool handlers
+	handleAllocationCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args AllocationArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
+		// Parse step duration if provided
+		var step time.Duration
+		var err error
+		if args.Step != "" {
+			step, err = time.ParseDuration(args.Step)
+			if err != nil {
+				return nil, nil, fmt.Errorf("invalid step duration '%s': %w", args.Step, err)
+			}
+		}
+
+		queryRequest := &opencost_mcp.OpenCostQueryRequest{
+			QueryType: opencost_mcp.AllocationQueryType,
+			Window:    args.Window,
+			AllocationParams: &opencost_mcp.AllocationQuery{
+				Step:                                  step,
+				Accumulate:                            args.Accumulate,
+				ShareIdle:                             args.ShareIdle,
+				Aggregate:                             args.Aggregate,
+				IncludeIdle:                           args.IncludeIdle,
+				IdleByNode:                            args.IdleByNode,
+				IncludeProportionalAssetResourceCosts: args.IncludeProportionalAssetResourceCosts,
+				IncludeAggregatedMetadata:             args.IncludeAggregatedMetadata,
+				ShareLB:                               args.ShareLB,
+				Filter:                                args.Filter,
+			},
+		}
+
+		mcpReq := &opencost_mcp.MCPRequest{
+			Query: queryRequest,
+		}
+
+		mcpResp, err := mcpServer.ProcessMCPRequest(mcpReq)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to process allocation request: %w", err)
+		}
+
+		return nil, mcpResp, nil
+	}
+
+	handleAssetCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args AssetArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
+		queryRequest := &opencost_mcp.OpenCostQueryRequest{
+			QueryType:   opencost_mcp.AssetQueryType,
+			Window:      args.Window,
+			AssetParams: &opencost_mcp.AssetQuery{},
+		}
+
+		mcpReq := &opencost_mcp.MCPRequest{
+			Query: queryRequest,
+		}
+
+		mcpResp, err := mcpServer.ProcessMCPRequest(mcpReq)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to process asset request: %w", err)
+		}
+
+		return nil, mcpResp, nil
+	}
+
+	handleCloudCosts := func(ctx context.Context, req *mcp_sdk.CallToolRequest, args CloudCostArgs) (*mcp_sdk.CallToolResult, interface{}, error) {
+		queryRequest := &opencost_mcp.OpenCostQueryRequest{
+			QueryType: opencost_mcp.CloudCostQueryType,
+			Window:    args.Window,
+			CloudCostParams: &opencost_mcp.CloudCostQuery{
+				Aggregate:  args.Aggregate,
+				Accumulate: args.Accumulate,
+				Filter:     args.Filter,
+				Provider:   args.Provider,
+				Service:    args.Service,
+				Category:   args.Category,
+				Region:     args.Region,
+				AccountID:  args.Account,
+			},
+		}
+
+		mcpReq := &opencost_mcp.MCPRequest{
+			Query: queryRequest,
+		}
+
+		mcpResp, err := mcpServer.ProcessMCPRequest(mcpReq)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to process cloud cost request: %w", err)
+		}
+
+		return nil, mcpResp, nil
+	}
+
+	// Register tools
+	mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
+		Name:        "get_allocation_costs",
+		Description: "Retrieves allocation cost data.",
+	}, handleAllocationCosts)
+
+	mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
+		Name:        "get_asset_costs",
+		Description: "Retrieves asset cost data.",
+	}, handleAssetCosts)
+
+	mcp_sdk.AddTool(sdkServer, &mcp_sdk.Tool{
+		Name:        "get_cloud_costs",
+		Description: "Retrieves cloud cost data.",
+	}, handleCloudCosts)
+
+	// Create HTTP handler
+	handler := mcp_sdk.NewStreamableHTTPHandler(func(r *http.Request) *mcp_sdk.Server {
+		return sdkServer
+	}, &mcp_sdk.StreamableHTTPOptions{
+		JSONResponse: true,
+	})
+
+	// Add logging middleware
+	loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		log.Debugf("MCP HTTP request: %s %s from %s", req.Method, req.URL.Path, req.RemoteAddr)
+		handler.ServeHTTP(w, req)
+	})
+
+	// Start HTTP server on configured port
+	port := env.GetMCPHTTPPort()
+	log.Infof("Starting MCP HTTP server on port %d...", port)
+
+	server := &http.Server{
+		Addr:    fmt.Sprintf(":%d", port),
+		Handler: loggingHandler,
+	}
+
+	// Start server in a goroutine
+	go func() {
+		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			log.Errorf("MCP server failed: %v", err)
+		}
+	}()
+
+	log.Info("MCP server started successfully")
+	return nil
+}
+
+// Tool argument structures for MCP server
+type AllocationArgs struct {
+	Window    string `json:"window"`
+	Aggregate string `json:"aggregate"`
+
+	// Allocation query parameters
+	Step                                  string `json:"step,omitempty"`
+	Resolution                            string `json:"resolution,omitempty"`
+	Accumulate                            bool   `json:"accumulate,omitempty"`
+	ShareIdle                             bool   `json:"share_idle,omitempty"`
+	IncludeIdle                           bool   `json:"include_idle,omitempty"`
+	IdleByNode                            bool   `json:"idle_by_node,omitempty"`
+	IncludeProportionalAssetResourceCosts bool   `json:"include_proportional_asset_resource_costs,omitempty"`
+	IncludeAggregatedMetadata             bool   `json:"include_aggregated_metadata,omitempty"`
+	ShareLB                               bool   `json:"share_lb,omitempty"`
+	Filter                                string `json:"filter,omitempty"`
+}
+
+type AssetArgs struct {
+	Window string `json:"window"`
+}
+
+type CloudCostArgs struct {
+	Window    string `json:"window"`
+	Aggregate string `json:"aggregate"`
+
+	// Cloud cost query parameters
+	Accumulate string `json:"accumulate,omitempty"`
+	Filter     string `json:"filter,omitempty"`
+	Provider   string `json:"provider,omitempty"`
+	Service    string `json:"service,omitempty"`
+	Category   string `json:"category,omitempty"`
+	Region     string `json:"region,omitempty"`
+	Account    string `json:"account,omitempty"`
+}

+ 3 - 1
pkg/costmodel/router.go

@@ -636,7 +636,7 @@ func GetDefaultCollectorStorage() storage.Storage {
 }
 }
 
 
 // InitializeCloudCost Initializes Cloud Cost pipeline and querier and registers endpoints
 // InitializeCloudCost Initializes Cloud Cost pipeline and querier and registers endpoints
-func InitializeCloudCost(router *httprouter.Router, providerConfig models.ProviderConfig) {
+func InitializeCloudCost(router *httprouter.Router, providerConfig models.ProviderConfig) *cloudcost.PipelineService {
 	log.Debugf("Cloud Cost config path: %s", env.GetCloudCostConfigPath())
 	log.Debugf("Cloud Cost config path: %s", env.GetCloudCostConfigPath())
 	cloudConfigController := cloudconfig.NewMemoryController(providerConfig)
 	cloudConfigController := cloudconfig.NewMemoryController(providerConfig)
 
 
@@ -658,6 +658,8 @@ func InitializeCloudCost(router *httprouter.Router, providerConfig models.Provid
 	router.GET("/cloudCost/status", cloudCostPipelineService.GetCloudCostStatusHandler())
 	router.GET("/cloudCost/status", cloudCostPipelineService.GetCloudCostStatusHandler())
 	router.GET("/cloudCost/rebuild", cloudCostPipelineService.GetCloudCostRebuildHandler())
 	router.GET("/cloudCost/rebuild", cloudCostPipelineService.GetCloudCostRebuildHandler())
 	router.GET("/cloudCost/repair", cloudCostPipelineService.GetCloudCostRepairHandler())
 	router.GET("/cloudCost/repair", cloudCostPipelineService.GetCloudCostRepairHandler())
+
+	return cloudCostPipelineService
 }
 }
 
 
 func InitializeCustomCost(router *httprouter.Router) *customcost.PipelineService {
 func InitializeCustomCost(router *httprouter.Router) *customcost.PipelineService {

+ 16 - 0
pkg/env/costmodel.go

@@ -86,6 +86,10 @@ const (
 
 
 	// Cloud provider override
 	// Cloud provider override
 	CloudProviderVar = "CLOUD_PROVIDER"
 	CloudProviderVar = "CLOUD_PROVIDER"
+
+	// MCP Server
+	MCPServerEnabledEnvVar = "MCP_SERVER_ENABLED"
+	MCPHTTPPortEnvVar      = "MCP_HTTP_PORT"
 )
 )
 
 
 func GetGCPAuthSecretFilePath() string {
 func GetGCPAuthSecretFilePath() string {
@@ -368,3 +372,15 @@ func GetLocalCollectorDirectory() string {
 func GetDOKSPricingURL() string {
 func GetDOKSPricingURL() string {
 	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/billing/pricing")
 	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/billing/pricing")
 }
 }
+
+// IsMCPServerEnabled returns the environment variable value for MCPServerEnabledEnvVar which represents
+// whether or not the MCP server is enabled.
+func IsMCPServerEnabled() bool {
+	return env.GetBool(MCPServerEnabledEnvVar, true)
+}
+
+// GetMCPHTTPPort returns the environment variable value for MCPHTTPPortEnvVar which represents
+// the HTTP port for the MCP server.
+func GetMCPHTTPPort() int {
+	return env.GetInt(MCPHTTPPortEnvVar, 8081)
+}

+ 640 - 12
pkg/mcp/server.go

@@ -1,7 +1,19 @@
 package mcp
 package mcp
 
 
 import (
 import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"strings"
 	"time"
 	"time"
+
+	"github.com/go-playground/validator/v10"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/filter/allocation"
+	cloudcostfilter "github.com/opencost/opencost/core/pkg/filter/cloudcost"
+	"github.com/opencost/opencost/core/pkg/opencost"
 	models "github.com/opencost/opencost/pkg/cloud/models"
 	models "github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/costmodel"
 	"github.com/opencost/opencost/pkg/costmodel"
@@ -26,7 +38,6 @@ type MCPRequest struct {
 type MCPResponse struct {
 type MCPResponse struct {
 	Data      interface{}   `json:"data"`
 	Data      interface{}   `json:"data"`
 	QueryInfo QueryMetadata `json:"queryInfo"`
 	QueryInfo QueryMetadata `json:"queryInfo"`
-	Summary   *DataSummary  `json:"summary,omitempty"`
 }
 }
 
 
 // QueryMetadata contains metadata about the query execution.
 // QueryMetadata contains metadata about the query execution.
@@ -36,12 +47,6 @@ type QueryMetadata struct {
 	ProcessingTime time.Duration `json:"processingTime"`
 	ProcessingTime time.Duration `json:"processingTime"`
 }
 }
 
 
-// DataSummary provides a summary of the data.
-type DataSummary struct {
-	Title   string `json:"title"`
-	Content string `json:"content"`
-}
-
 // OpenCostQueryRequest provides a unified interface for all OpenCost query types.
 // OpenCostQueryRequest provides a unified interface for all OpenCost query types.
 type OpenCostQueryRequest struct {
 type OpenCostQueryRequest struct {
 	QueryType QueryType `json:"queryType" validate:"required,oneof=allocation asset cloudcost"`
 	QueryType QueryType `json:"queryType" validate:"required,oneof=allocation asset cloudcost"`
@@ -64,6 +69,7 @@ type AllocationQuery struct {
 	IncludeProportionalAssetResourceCosts bool          `json:"includeProportionalAssetResourceCosts,omitempty"`
 	IncludeProportionalAssetResourceCosts bool          `json:"includeProportionalAssetResourceCosts,omitempty"`
 	IncludeAggregatedMetadata             bool          `json:"includeAggregatedMetadata,omitempty"`
 	IncludeAggregatedMetadata             bool          `json:"includeAggregatedMetadata,omitempty"`
 	ShareLB                               bool          `json:"sharelb,omitempty"`
 	ShareLB                               bool          `json:"sharelb,omitempty"`
+	Filter                                string        `json:"filter,omitempty"` // Filter expression for allocations (e.g., "cluster:production", "namespace:kube-system")
 }
 }
 
 
 // AssetQuery contains the parameters for an asset query.
 // AssetQuery contains the parameters for an asset query.
@@ -80,7 +86,11 @@ type CloudCostQuery struct {
 	Service    string `json:"service,omitempty"`    // Service filter (ec2, s3, compute, etc.)
 	Service    string `json:"service,omitempty"`    // Service filter (ec2, s3, compute, etc.)
 	Category   string `json:"category,omitempty"`   // Category filter (compute, storage, network, etc.)
 	Category   string `json:"category,omitempty"`   // Category filter (compute, storage, network, etc.)
 	Region     string `json:"region,omitempty"`     // Region filter
 	Region     string `json:"region,omitempty"`     // Region filter
-	Account    string `json:"account,omitempty"`    // Account filter
+	// Additional explicit fields for filtering
+	AccountID       string            `json:"accountID,omitempty"`       // Alias of Account; maps to accountID
+	InvoiceEntityID string            `json:"invoiceEntityID,omitempty"` // Invoice entity ID filter
+	ProviderID      string            `json:"providerID,omitempty"`      // Cloud provider resource ID filter
+	Labels          map[string]string `json:"labels,omitempty"`          // Label filters (key->value)
 }
 }
 
 
 // AllocationResponse represents the allocation data returned to the AI agent.
 // AllocationResponse represents the allocation data returned to the AI agent.
@@ -186,6 +196,13 @@ type Asset struct {
 
 
 	// Overhead (Node-specific)
 	// Overhead (Node-specific)
 	Overhead *NodeOverhead `json:"overhead,omitempty"`
 	Overhead *NodeOverhead `json:"overhead,omitempty"`
+
+	// LoadBalancer-specific fields
+	Private bool   `json:"private,omitempty"`
+	Ip      string `json:"ip,omitempty"`
+
+	// Cloud-specific fields
+	Credit float64 `json:"credit,omitempty"`
 }
 }
 
 
 // NodeOverhead represents node overhead information
 // NodeOverhead represents node overhead information
@@ -286,7 +303,618 @@ type CostMetric struct {
 
 
 // MCPServer holds the dependencies for the MCP API server.
 // MCPServer holds the dependencies for the MCP API server.
 type MCPServer struct {
 type MCPServer struct {
-	costModel   *costmodel.CostModel
-	provider    models.Provider
-	integration cloudcost.CloudCostIntegration
-}
+	costModel    *costmodel.CostModel
+	provider     models.Provider
+	cloudQuerier cloudcost.Querier
+}
+
+// NewMCPServer creates a new MCP Server.
+func NewMCPServer(costModel *costmodel.CostModel, provider models.Provider, cloudQuerier cloudcost.Querier) *MCPServer {
+	return &MCPServer{
+		costModel:    costModel,
+		provider:     provider,
+		cloudQuerier: cloudQuerier,
+	}
+}
+
+// ProcessMCPRequest processes an MCP request and returns an MCP response.
+
+func (s *MCPServer) ProcessMCPRequest(request *MCPRequest) (*MCPResponse, error) {
+	// 1. Validate Request
+	if err := validate.Struct(request); err != nil {
+		return nil, fmt.Errorf("validation failed: %w", err)
+	}
+
+	// 2. Query Dispatching
+	var data interface{}
+	var err error
+
+	queryStart := time.Now()
+
+	switch request.Query.QueryType {
+	case AllocationQueryType:
+		data, err = s.QueryAllocations(request.Query)
+	case AssetQueryType:
+		data, err = s.QueryAssets(request.Query)
+	case CloudCostQueryType:
+		data, err = s.QueryCloudCosts(request.Query)
+	default:
+		return nil, fmt.Errorf("unsupported query type: %s", request.Query.QueryType)
+	}
+
+	if err != nil {
+		// Handle error appropriately, maybe return a JSON-RPC error response
+		return nil, err
+	}
+
+	processingTime := time.Since(queryStart)
+
+	// 3. Construct Final Response
+	mcpResponse := &MCPResponse{
+		Data: data,
+		QueryInfo: QueryMetadata{
+			QueryID:        generateQueryID(),
+			Timestamp:      time.Now(),
+			ProcessingTime: processingTime,
+		},
+	}
+	return mcpResponse, nil
+}
+
+// validate is the singleton validator instance.
+var validate = validator.New()
+
+func generateQueryID() string {
+	bytes := make([]byte, 8) // 16 hex characters
+	if _, err := rand.Read(bytes); err != nil {
+		// Fallback to timestamp-based ID if crypto/rand fails
+		return fmt.Sprintf("query-%d", time.Now().UnixNano())
+	}
+	return fmt.Sprintf("query-%s", hex.EncodeToString(bytes))
+}
+
+func (s *MCPServer) QueryAllocations(query *OpenCostQueryRequest) (*AllocationResponse, error) {
+	// 1. Parse Window
+	window, err := opencost.ParseWindowWithOffset(query.Window, 0) // 0 offset for UTC
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse window '%s': %w", query.Window, err)
+	}
+
+	// 2. Set default parameters
+	var step time.Duration
+	var aggregateBy []string
+	var includeIdle, idleByNode, includeProportionalAssetResourceCosts, includeAggregatedMetadata, sharedLoadBalancer, shareIdle bool
+	var accumulateBy opencost.AccumulateOption
+	var filterString string
+
+	// 3. Parse allocation parameters if provided
+	if query.AllocationParams != nil {
+		// Set step duration (default to window duration if not specified)
+		if query.AllocationParams.Step > 0 {
+			step = query.AllocationParams.Step
+		} else {
+			step = window.Duration()
+		}
+
+		// Parse aggregation properties
+		if query.AllocationParams.Aggregate != "" {
+			aggregateBy = strings.Split(query.AllocationParams.Aggregate, ",")
+		}
+
+		// Set boolean parameters
+		includeIdle = query.AllocationParams.IncludeIdle
+		idleByNode = query.AllocationParams.IdleByNode
+		includeProportionalAssetResourceCosts = query.AllocationParams.IncludeProportionalAssetResourceCosts
+		includeAggregatedMetadata = query.AllocationParams.IncludeAggregatedMetadata
+		sharedLoadBalancer = query.AllocationParams.ShareLB
+		shareIdle = query.AllocationParams.ShareIdle
+
+		// Set filter string
+		filterString = query.AllocationParams.Filter
+
+		// Validate filter string if provided
+		if filterString != "" {
+			parser := allocation.NewAllocationFilterParser()
+			_, err := parser.Parse(filterString)
+			if err != nil {
+				return nil, fmt.Errorf("invalid allocation filter '%s': %w", filterString, err)
+			}
+		}
+
+		// Set accumulation option
+		if query.AllocationParams.Accumulate {
+			accumulateBy = opencost.AccumulateOptionAll
+		} else {
+			accumulateBy = opencost.AccumulateOptionNone
+		}
+	} else {
+		// Default values when no parameters provided
+		step = window.Duration()
+		accumulateBy = opencost.AccumulateOptionNone
+		filterString = ""
+	}
+
+	// 4. Call the existing QueryAllocation function with all parameters
+	asr, err := s.costModel.QueryAllocation(
+		window,
+		step,
+		aggregateBy,
+		includeIdle,
+		idleByNode,
+		includeProportionalAssetResourceCosts,
+		includeAggregatedMetadata,
+		sharedLoadBalancer,
+		accumulateBy,
+		shareIdle,
+		filterString,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query allocations: %w", err)
+	}
+
+	// 5. Handle the AllocationSetRange result
+	if asr == nil || len(asr.Allocations) == 0 {
+		return &AllocationResponse{
+			Allocations: make(map[string]*AllocationSet),
+		}, nil
+	}
+
+	// 6. Transform the result to MCP format
+	// If we have multiple sets, we'll combine them or return the first one
+	// For now, let's return the first allocation set
+	firstSet := asr.Allocations[0]
+	return transformAllocationSet(firstSet), nil
+}
+
+// transformAllocationSet converts an opencost.AllocationSet into the MCP's AllocationResponse format.
+func transformAllocationSet(allocSet *opencost.AllocationSet) *AllocationResponse {
+	if allocSet == nil {
+		return &AllocationResponse{Allocations: make(map[string]*AllocationSet)}
+	}
+
+	mcpAllocations := make(map[string]*AllocationSet)
+
+	// Create a single set for all allocations
+	mcpSet := &AllocationSet{
+		Name:        "allocations",
+		Allocations: []*Allocation{},
+	}
+
+	// Convert each allocation
+	for _, alloc := range allocSet.Allocations {
+		if alloc == nil {
+			continue
+		}
+
+		mcpAlloc := &Allocation{
+			Name:         alloc.Name,
+			CPUCost:      alloc.CPUCost,
+			GPUCost:      alloc.GPUCost,
+			RAMCost:      alloc.RAMCost,
+			PVCost:       alloc.PVCost(), // Call the method
+			NetworkCost:  alloc.NetworkCost,
+			SharedCost:   alloc.SharedCost,
+			ExternalCost: alloc.ExternalCost,
+			TotalCost:    alloc.TotalCost(),
+			CPUCoreHours: alloc.CPUCoreHours,
+			RAMByteHours: alloc.RAMByteHours,
+			GPUHours:     alloc.GPUHours,
+			PVByteHours:  alloc.PVBytes(), // Use the method directly
+			Start:        alloc.Start,
+			End:          alloc.End,
+		}
+		mcpSet.Allocations = append(mcpSet.Allocations, mcpAlloc)
+	}
+
+	mcpAllocations["allocations"] = mcpSet
+
+	return &AllocationResponse{
+		Allocations: mcpAllocations,
+	}
+}
+
+func (s *MCPServer) QueryAssets(query *OpenCostQueryRequest) (*AssetResponse, error) {
+	// 1. Parse Window
+	window, err := opencost.ParseWindowWithOffset(query.Window, 0) // 0 offset for UTC
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse window '%s': %w", query.Window, err)
+	}
+
+	// 2. Set Query Options
+	start := *window.Start()
+	end := *window.End()
+
+	// 3. Call CostModel to get the asset set
+	assetSet, err := s.costModel.ComputeAssets(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("failed to compute assets: %w", err)
+	}
+
+	// 4. Transform Response for the MCP API
+	return transformAssetSet(assetSet), nil
+}
+
+// transformAssetSet converts a opencost.AssetSet into the MCP's AssetResponse format.
+func transformAssetSet(assetSet *opencost.AssetSet) *AssetResponse {
+	if assetSet == nil {
+		return &AssetResponse{Assets: make(map[string]*AssetSet)}
+	}
+
+	mcpAssets := make(map[string]*AssetSet)
+
+	// Create a single set for all assets
+	mcpSet := &AssetSet{
+		Name:   "assets",
+		Assets: []*Asset{},
+	}
+
+	for _, asset := range assetSet.Assets {
+		if asset == nil {
+			continue
+		}
+
+		properties := asset.GetProperties()
+		labels := asset.GetLabels()
+
+		mcpAsset := &Asset{
+			Type: asset.Type().String(),
+			Properties: AssetProperties{
+				Category:   properties.Category,
+				Provider:   properties.Provider,
+				Account:    properties.Account,
+				Project:    properties.Project,
+				Service:    properties.Service,
+				Cluster:    properties.Cluster,
+				Name:       properties.Name,
+				ProviderID: properties.ProviderID,
+			},
+			Labels:     labels,
+			Start:      asset.GetStart(),
+			End:        asset.GetEnd(),
+			Minutes:    asset.Minutes(),
+			Adjustment: asset.GetAdjustment(),
+			TotalCost:  asset.TotalCost(),
+		}
+
+		// Handle type-specific fields
+		switch a := asset.(type) {
+		case *opencost.Disk:
+			mcpAsset.ByteHours = a.ByteHours
+			mcpAsset.ByteHoursUsed = a.ByteHoursUsed
+			mcpAsset.ByteUsageMax = a.ByteUsageMax
+			mcpAsset.StorageClass = a.StorageClass
+			mcpAsset.VolumeName = a.VolumeName
+			mcpAsset.ClaimName = a.ClaimName
+			mcpAsset.ClaimNamespace = a.ClaimNamespace
+			mcpAsset.Local = a.Local
+			if a.Breakdown != nil {
+				mcpAsset.Breakdown = &AssetBreakdown{
+					Idle:   a.Breakdown.Idle,
+					Other:  a.Breakdown.Other,
+					System: a.Breakdown.System,
+					User:   a.Breakdown.User,
+				}
+			}
+		case *opencost.Node:
+			mcpAsset.NodeType = a.NodeType
+			mcpAsset.CPUCoreHours = a.CPUCoreHours
+			mcpAsset.RAMByteHours = a.RAMByteHours
+			mcpAsset.GPUHours = a.GPUHours
+			mcpAsset.GPUCount = a.GPUCount
+			mcpAsset.CPUCost = a.CPUCost
+			mcpAsset.GPUCost = a.GPUCost
+			mcpAsset.RAMCost = a.RAMCost
+			mcpAsset.Discount = a.Discount
+			mcpAsset.Preemptible = a.Preemptible
+			if a.CPUBreakdown != nil {
+				mcpAsset.CPUBreakdown = &AssetBreakdown{
+					Idle:   a.CPUBreakdown.Idle,
+					Other:  a.CPUBreakdown.Other,
+					System: a.CPUBreakdown.System,
+					User:   a.CPUBreakdown.User,
+				}
+			}
+			if a.RAMBreakdown != nil {
+				mcpAsset.RAMBreakdown = &AssetBreakdown{
+					Idle:   a.RAMBreakdown.Idle,
+					Other:  a.RAMBreakdown.Other,
+					System: a.RAMBreakdown.System,
+					User:   a.RAMBreakdown.User,
+				}
+			}
+			if a.Overhead != nil {
+				mcpAsset.Overhead = &NodeOverhead{
+					RamOverheadFraction:  a.Overhead.RamOverheadFraction,
+					CpuOverheadFraction:  a.Overhead.CpuOverheadFraction,
+					OverheadCostFraction: a.Overhead.OverheadCostFraction,
+				}
+			}
+		case *opencost.LoadBalancer:
+			mcpAsset.Private = a.Private
+			mcpAsset.Ip = a.Ip
+		case *opencost.Network:
+			// Network assets have no specific fields beyond the base asset structure
+			// All relevant data is in Properties, Labels, Cost, etc.
+		case *opencost.Cloud:
+			mcpAsset.Credit = a.Credit
+		case *opencost.ClusterManagement:
+			// ClusterManagement assets have no specific fields beyond the base asset structure
+			// All relevant data is in Properties, Labels, Cost, etc.
+		}
+
+		mcpSet.Assets = append(mcpSet.Assets, mcpAsset)
+	}
+
+	mcpAssets["assets"] = mcpSet
+
+	return &AssetResponse{
+		Assets: mcpAssets,
+	}
+}
+
+// QueryCloudCosts translates an MCP query into a CloudCost repository query and transforms the result.
+func (s *MCPServer) QueryCloudCosts(query *OpenCostQueryRequest) (*CloudCostResponse, error) {
+	// 1. Check if cloud cost querier is available
+	if s.cloudQuerier == nil {
+		return nil, fmt.Errorf("cloud cost querier not configured - check cloud-integration.json file")
+	}
+
+	// 2. Parse Window
+	window, err := opencost.ParseWindowWithOffset(query.Window, 0) // 0 offset for UTC
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse window '%s': %w", query.Window, err)
+	}
+
+	// 3. Build query request
+	request := cloudcost.QueryRequest{
+		Start:  *window.Start(),
+		End:    *window.End(),
+		Filter: nil, // Will be set from CloudCostParams if provided
+	}
+
+	// 4. Apply filtering and aggregation from CloudCostParams
+	if query.CloudCostParams != nil {
+		request = s.buildCloudCostQueryRequest(request, query.CloudCostParams)
+	}
+
+	// 5. Query the repository (this handles multiple cloud providers automatically)
+	ccsr, err := s.cloudQuerier.Query(context.TODO(), request)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query cloud costs: %w", err)
+	}
+
+	// 6. Transform Response
+	return transformCloudCostSetRange(ccsr), nil
+}
+
+// buildCloudCostQueryRequest builds a QueryRequest from CloudCostParams
+func (s *MCPServer) buildCloudCostQueryRequest(request cloudcost.QueryRequest, params *CloudCostQuery) cloudcost.QueryRequest {
+	// Set aggregation
+	if params.Aggregate != "" {
+		aggregateBy := strings.Split(params.Aggregate, ",")
+		request.AggregateBy = aggregateBy
+	}
+
+	// Set accumulation
+	if params.Accumulate != "" {
+		request.Accumulate = opencost.ParseAccumulate(params.Accumulate)
+	}
+
+	// Build filter from individual parameters or filter string
+	var filter filter.Filter
+	var err error
+
+	if params.Filter != "" {
+		// Parse the filter string directly
+		parser := cloudcostfilter.NewCloudCostFilterParser()
+		filter, err = parser.Parse(params.Filter)
+		if err != nil {
+			// Log error but continue without filter rather than failing the entire request
+			fmt.Printf("Warning: failed to parse filter string '%s': %v\n", params.Filter, err)
+		}
+	} else {
+		// Build filter from individual parameters
+		filter = s.buildFilterFromParams(params)
+	}
+
+	request.Filter = filter
+	return request
+}
+
+// buildFilterFromParams creates a filter from individual CloudCostQuery parameters
+func (s *MCPServer) buildFilterFromParams(params *CloudCostQuery) filter.Filter {
+	var filterParts []string
+
+	// Add provider filter
+	if params.Provider != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`provider:"%s"`, params.Provider))
+	}
+
+	// Add providerID filter
+	if params.ProviderID != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`providerID:"%s"`, params.ProviderID))
+	}
+
+	// Add service filter
+	if params.Service != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`service:"%s"`, params.Service))
+	}
+
+	// Add category filter
+	if params.Category != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`category:"%s"`, params.Category))
+	}
+
+	// Region is intentionally not supported here
+
+	// Add account filter (maps to accountID)
+	if params.AccountID != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`accountID:"%s"`, params.AccountID))
+	}
+
+	// Add invoiceEntityID filter
+	if params.InvoiceEntityID != "" {
+		filterParts = append(filterParts, fmt.Sprintf(`invoiceEntityID:"%s"`, params.InvoiceEntityID))
+	}
+
+	// Add label filters (label[key]:"value")
+	if len(params.Labels) > 0 {
+		for k, v := range params.Labels {
+			if k == "" {
+				continue
+			}
+			filterParts = append(filterParts, fmt.Sprintf(`label[%s]:"%s"`, k, v))
+		}
+	}
+
+	// If no filters specified, return nil
+	if len(filterParts) == 0 {
+		return nil
+	}
+
+	// Combine all filter parts with AND logic (parser expects 'and')
+	filterString := strings.Join(filterParts, " and ")
+
+	// Parse the combined filter string
+	parser := cloudcostfilter.NewCloudCostFilterParser()
+	filter, err := parser.Parse(filterString)
+	if err != nil {
+		// Log error but return nil rather than failing
+		fmt.Printf("Warning: failed to parse combined filter '%s': %v\n", filterString, err)
+		return nil
+	}
+
+	return filter
+}
+
+// transformCloudCostSetRange converts a opencost.CloudCostSetRange into the MCP's CloudCostResponse format.
+func transformCloudCostSetRange(ccsr *opencost.CloudCostSetRange) *CloudCostResponse {
+	if ccsr == nil || len(ccsr.CloudCostSets) == 0 {
+		return &CloudCostResponse{
+			CloudCosts: make(map[string]*CloudCostSet),
+			Summary: &CloudCostSummary{
+				TotalNetCost: 0,
+			},
+		}
+	}
+
+	mcpCloudCosts := make(map[string]*CloudCostSet)
+	var totalNetCost, totalAmortizedCost, totalInvoicedCost float64
+	providerBreakdown := make(map[string]float64)
+	serviceBreakdown := make(map[string]float64)
+	regionBreakdown := make(map[string]float64)
+
+	// Process each cloud cost set in the range
+	for i, ccSet := range ccsr.CloudCostSets {
+		if ccSet == nil {
+			continue
+		}
+
+		setName := fmt.Sprintf("cloudcosts_%d", i)
+		mcpSet := &CloudCostSet{
+			Name:                  setName,
+			CloudCosts:            []*CloudCost{},
+			AggregationProperties: ccSet.AggregationProperties,
+			Window: &TimeWindow{
+				Start: *ccSet.Window.Start(),
+				End:   *ccSet.Window.End(),
+			},
+		}
+
+		// Convert each cloud cost item
+		for _, item := range ccSet.CloudCosts {
+			if item == nil {
+				continue
+			}
+
+			mcpCC := &CloudCost{
+				Properties: CloudCostProperties{
+					ProviderID:        item.Properties.ProviderID,
+					Provider:          item.Properties.Provider,
+					AccountID:         item.Properties.AccountID,
+					AccountName:       item.Properties.AccountName,
+					InvoiceEntityID:   item.Properties.InvoiceEntityID,
+					InvoiceEntityName: item.Properties.InvoiceEntityName,
+					RegionID:          item.Properties.RegionID,
+					AvailabilityZone:  item.Properties.AvailabilityZone,
+					Service:           item.Properties.Service,
+					Category:          item.Properties.Category,
+					Labels:            item.Properties.Labels,
+				},
+				Window: TimeWindow{
+					Start: *item.Window.Start(),
+					End:   *item.Window.End(),
+				},
+				ListCost: CostMetric{
+					Cost:              item.ListCost.Cost,
+					KubernetesPercent: item.ListCost.KubernetesPercent,
+				},
+				NetCost: CostMetric{
+					Cost:              item.NetCost.Cost,
+					KubernetesPercent: item.NetCost.KubernetesPercent,
+				},
+				AmortizedNetCost: CostMetric{
+					Cost:              item.AmortizedNetCost.Cost,
+					KubernetesPercent: item.AmortizedNetCost.KubernetesPercent,
+				},
+				InvoicedCost: CostMetric{
+					Cost:              item.InvoicedCost.Cost,
+					KubernetesPercent: item.InvoicedCost.KubernetesPercent,
+				},
+				AmortizedCost: CostMetric{
+					Cost:              item.AmortizedCost.Cost,
+					KubernetesPercent: item.AmortizedCost.KubernetesPercent,
+				},
+			}
+			mcpSet.CloudCosts = append(mcpSet.CloudCosts, mcpCC)
+
+			// Update summary totals
+			totalNetCost += item.NetCost.Cost
+			totalAmortizedCost += item.AmortizedNetCost.Cost
+			totalInvoicedCost += item.InvoicedCost.Cost
+
+			// Update breakdowns
+			providerBreakdown[item.Properties.Provider] += item.NetCost.Cost
+			serviceBreakdown[item.Properties.Service] += item.NetCost.Cost
+			regionBreakdown[item.Properties.RegionID] += item.NetCost.Cost
+		}
+
+		mcpCloudCosts[setName] = mcpSet
+	}
+
+	// Calculate cost-weighted average Kubernetes percentage (by NetCost)
+	var avgKubernetesPercent float64
+	var numerator, denominator float64
+	for _, ccSet := range ccsr.CloudCostSets {
+		for _, item := range ccSet.CloudCosts {
+			if item == nil {
+				continue
+			}
+			cost := item.NetCost.Cost
+			percent := item.NetCost.KubernetesPercent
+			if cost <= 0 {
+				continue
+			}
+			numerator += cost * percent
+			denominator += cost
+		}
+	}
+	if denominator > 0 {
+		avgKubernetesPercent = numerator / denominator
+	}
+
+	summary := &CloudCostSummary{
+		TotalNetCost:       totalNetCost,
+		TotalAmortizedCost: totalAmortizedCost,
+		TotalInvoicedCost:  totalInvoicedCost,
+		KubernetesPercent:  avgKubernetesPercent,
+		ProviderBreakdown:  providerBreakdown,
+		ServiceBreakdown:   serviceBreakdown,
+		RegionBreakdown:    regionBreakdown,
+	}
+
+	return &CloudCostResponse{
+		CloudCosts: mcpCloudCosts,
+		Summary:    summary,
+	}
+}

+ 912 - 0
pkg/mcp/server_test.go

@@ -0,0 +1,912 @@
+package mcp
+
+import (
+	"context"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	models "github.com/opencost/opencost/pkg/cloud/models"
+	"github.com/opencost/opencost/pkg/cloudcost"
+	"github.com/opencost/opencost/pkg/costmodel"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestQueryTypeConstants(t *testing.T) {
+	assert.Equal(t, QueryType("allocation"), AllocationQueryType)
+	assert.Equal(t, QueryType("asset"), AssetQueryType)
+	assert.Equal(t, QueryType("cloudcost"), CloudCostQueryType)
+}
+
+func TestAllocationQueryStruct(t *testing.T) {
+	query := AllocationQuery{
+		Step:                                  1 * time.Hour,
+		Accumulate:                            true,
+		ShareIdle:                             true,
+		Aggregate:                             "namespace",
+		IncludeIdle:                           true,
+		IdleByNode:                            true,
+		IncludeProportionalAssetResourceCosts: true,
+		IncludeAggregatedMetadata:             true,
+		ShareLB:                               true,
+	}
+
+	assert.Equal(t, 1*time.Hour, query.Step)
+	assert.True(t, query.Accumulate)
+	assert.True(t, query.ShareIdle)
+	assert.Equal(t, "namespace", query.Aggregate)
+	assert.True(t, query.IncludeIdle)
+	assert.True(t, query.IdleByNode)
+	assert.True(t, query.IncludeProportionalAssetResourceCosts)
+	assert.True(t, query.IncludeAggregatedMetadata)
+	assert.True(t, query.ShareLB)
+}
+
+func TestAssetQueryStruct(t *testing.T) {
+	query := AssetQuery{}
+
+	// AssetQuery is currently empty, just test that it can be created
+	assert.NotNil(t, query)
+}
+
+func TestCloudCostQueryStruct(t *testing.T) {
+	query := CloudCostQuery{
+		Aggregate:  "provider,service",
+		Accumulate: "day",
+		Filter:     "provider=aws",
+		Provider:   "aws",
+		Service:    "ec2",
+		Category:   "compute",
+		Region:     "us-east-1",
+		AccountID:  "123456789",
+	}
+
+	assert.Equal(t, "provider,service", query.Aggregate)
+	assert.Equal(t, "day", query.Accumulate)
+	assert.Equal(t, "provider=aws", query.Filter)
+	assert.Equal(t, "aws", query.Provider)
+	assert.Equal(t, "ec2", query.Service)
+	assert.Equal(t, "compute", query.Category)
+	assert.Equal(t, "us-east-1", query.Region)
+	assert.Equal(t, "123456789", query.AccountID)
+}
+
+func TestMCPRequestStruct(t *testing.T) {
+	request := MCPRequest{
+		SessionID: "test-session-123",
+		Query: &OpenCostQueryRequest{
+			QueryType: AllocationQueryType,
+			Window:    "24h",
+			AllocationParams: &AllocationQuery{
+				Step:       1 * time.Hour,
+				Accumulate: true,
+				ShareIdle:  true,
+			},
+		},
+	}
+
+	assert.Equal(t, "test-session-123", request.SessionID)
+	assert.NotNil(t, request.Query)
+	assert.Equal(t, AllocationQueryType, request.Query.QueryType)
+	assert.Equal(t, "24h", request.Query.Window)
+	assert.NotNil(t, request.Query.AllocationParams)
+	assert.Equal(t, 1*time.Hour, request.Query.AllocationParams.Step)
+	assert.True(t, request.Query.AllocationParams.Accumulate)
+	assert.True(t, request.Query.AllocationParams.ShareIdle)
+}
+
+func TestMCPResponseStruct(t *testing.T) {
+	response := MCPResponse{
+		Data: "test-data",
+		QueryInfo: QueryMetadata{
+			QueryID:        "query-123",
+			Timestamp:      time.Now(),
+			ProcessingTime: 100 * time.Millisecond,
+		},
+	}
+
+	assert.Equal(t, "test-data", response.Data)
+	assert.Equal(t, "query-123", response.QueryInfo.QueryID)
+	assert.NotZero(t, response.QueryInfo.Timestamp)
+	assert.Equal(t, 100*time.Millisecond, response.QueryInfo.ProcessingTime)
+}
+
+func TestQueryMetadataStruct(t *testing.T) {
+	metadata := QueryMetadata{
+		QueryID:        "query-456",
+		Timestamp:      time.Now(),
+		ProcessingTime: 250 * time.Millisecond,
+	}
+
+	assert.Equal(t, "query-456", metadata.QueryID)
+	assert.NotZero(t, metadata.Timestamp)
+	assert.Equal(t, 250*time.Millisecond, metadata.ProcessingTime)
+}
+
+func TestOpenCostQueryRequestStruct(t *testing.T) {
+	request := OpenCostQueryRequest{
+		QueryType:   AssetQueryType,
+		Window:      "7d",
+		AssetParams: &AssetQuery{},
+	}
+
+	assert.Equal(t, AssetQueryType, request.QueryType)
+	assert.Equal(t, "7d", request.Window)
+	assert.NotNil(t, request.AssetParams)
+}
+
+// Test helper functions
+func createTestAllocation(name string) *Allocation {
+	now := time.Now()
+	return &Allocation{
+		Name:         name,
+		CPUCost:      10.0,
+		RAMCost:      5.0,
+		GPUCost:      0.0,
+		PVCost:       2.0,
+		NetworkCost:  1.0,
+		SharedCost:   0.5,
+		ExternalCost: 0.0,
+		TotalCost:    18.5,
+		CPUCoreHours: 100.0,
+		RAMByteHours: 5000000000.0,
+		GPUHours:     0.0,
+		PVByteHours:  2000000000.0,
+		Start:        now.Add(-24 * time.Hour),
+		End:          now,
+	}
+}
+
+func createTestAsset(name string) *Asset {
+	now := time.Now()
+	return &Asset{
+		Type: "node",
+		Properties: AssetProperties{
+			Category: "compute",
+			Provider: "aws",
+			Name:     name,
+		},
+		CPUCost:      50.0,
+		RAMCost:      25.0,
+		GPUCost:      100.0,
+		TotalCost:    175.0,
+		CPUCoreHours: 500.0,
+		RAMByteHours: 25000000000.0,
+		GPUHours:     50.0,
+		Start:        now.Add(-24 * time.Hour),
+		End:          now,
+	}
+}
+
+func createTestCloudCost(name string) *CloudCost {
+	now := time.Now()
+	return &CloudCost{
+		Properties: CloudCostProperties{
+			Provider: "aws",
+			Service:  "ec2",
+		},
+		Window: TimeWindow{
+			Start: now.Add(-24 * time.Hour),
+			End:   now,
+		},
+		ListCost: CostMetric{
+			Cost:              100.0,
+			KubernetesPercent: 80.0,
+		},
+		NetCost: CostMetric{
+			Cost:              95.0,
+			KubernetesPercent: 80.0,
+		},
+	}
+}
+
+// Test MCP server response structures
+func TestAllocationResponseStruct(t *testing.T) {
+	allocation := createTestAllocation("test-namespace")
+	allocationSet := &AllocationSet{
+		Name: "test-namespace",
+		Properties: map[string]string{
+			"namespace": "test-namespace",
+		},
+		Allocations: []*Allocation{allocation},
+	}
+
+	response := AllocationResponse{
+		Allocations: map[string]*AllocationSet{
+			"test-namespace": allocationSet,
+		},
+	}
+
+	require.NotNil(t, response.Allocations)
+	assert.Len(t, response.Allocations, 1)
+	assert.Contains(t, response.Allocations, "test-namespace")
+
+	allocSet := response.Allocations["test-namespace"]
+	assert.Equal(t, "test-namespace", allocSet.Name)
+	assert.Len(t, allocSet.Allocations, 1)
+
+	alloc := allocSet.Allocations[0]
+	assert.Equal(t, "test-namespace", alloc.Name)
+	assert.Equal(t, 10.0, alloc.CPUCost)
+	assert.Equal(t, 5.0, alloc.RAMCost)
+	assert.Equal(t, 18.5, alloc.TotalCost)
+}
+
+func TestAssetResponseStruct(t *testing.T) {
+	asset := createTestAsset("test-node")
+	assetSet := &AssetSet{
+		Name:   "test-node",
+		Assets: []*Asset{asset},
+	}
+
+	response := AssetResponse{
+		Assets: map[string]*AssetSet{
+			"test-node": assetSet,
+		},
+	}
+
+	require.NotNil(t, response.Assets)
+	assert.Len(t, response.Assets, 1)
+	assert.Contains(t, response.Assets, "test-node")
+
+	assetSetResult := response.Assets["test-node"]
+	assert.Equal(t, "test-node", assetSetResult.Name)
+	assert.Len(t, assetSetResult.Assets, 1)
+
+	assetResult := assetSetResult.Assets[0]
+	assert.Equal(t, "node", assetResult.Type)
+	assert.Equal(t, 50.0, assetResult.CPUCost)
+	assert.Equal(t, 25.0, assetResult.RAMCost)
+	assert.Equal(t, 100.0, assetResult.GPUCost)
+	assert.Equal(t, 175.0, assetResult.TotalCost)
+}
+
+func TestCloudCostResponseStruct(t *testing.T) {
+	cloudCost := createTestCloudCost("aws-ec2")
+	cloudCostSet := &CloudCostSet{
+		Name:       "aws-ec2",
+		CloudCosts: []*CloudCost{cloudCost},
+		Window: &TimeWindow{
+			Start: time.Now().Add(-24 * time.Hour),
+			End:   time.Now(),
+		},
+	}
+
+	response := CloudCostResponse{
+		CloudCosts: map[string]*CloudCostSet{
+			"aws-ec2": cloudCostSet,
+		},
+		Summary: &CloudCostSummary{
+			TotalNetCost:       95.0,
+			TotalAmortizedCost: 90.0,
+			TotalInvoicedCost:  100.0,
+			KubernetesPercent:  80.0,
+		},
+	}
+
+	require.NotNil(t, response.CloudCosts)
+	assert.Len(t, response.CloudCosts, 1)
+	assert.Contains(t, response.CloudCosts, "aws-ec2")
+
+	costSet := response.CloudCosts["aws-ec2"]
+	assert.Equal(t, "aws-ec2", costSet.Name)
+	assert.Len(t, costSet.CloudCosts, 1)
+
+	cost := costSet.CloudCosts[0]
+	assert.Equal(t, "aws", cost.Properties.Provider)
+	assert.Equal(t, "ec2", cost.Properties.Service)
+	assert.Equal(t, 100.0, cost.ListCost.Cost)
+	assert.Equal(t, 95.0, cost.NetCost.Cost)
+
+	require.NotNil(t, response.Summary)
+	assert.Equal(t, 95.0, response.Summary.TotalNetCost)
+	assert.Equal(t, 80.0, response.Summary.KubernetesPercent)
+}
+
+// Test allocation set functionality
+func TestAllocationSetTotalCost(t *testing.T) {
+	alloc1 := createTestAllocation("alloc1")
+	alloc1.TotalCost = 10.0
+
+	alloc2 := createTestAllocation("alloc2")
+	alloc2.TotalCost = 15.0
+
+	allocSet := &AllocationSet{
+		Name:        "test-set",
+		Allocations: []*Allocation{alloc1, alloc2},
+	}
+
+	totalCost := allocSet.TotalCost()
+	assert.Equal(t, 25.0, totalCost)
+}
+
+// Test asset properties
+func TestAssetProperties(t *testing.T) {
+	props := AssetProperties{
+		Category:   "compute",
+		Provider:   "aws",
+		Account:    "123456789",
+		Project:    "my-project",
+		Service:    "ec2",
+		Cluster:    "prod-cluster",
+		Name:       "worker-node-1",
+		ProviderID: "i-1234567890abcdef0",
+	}
+
+	assert.Equal(t, "compute", props.Category)
+	assert.Equal(t, "aws", props.Provider)
+	assert.Equal(t, "123456789", props.Account)
+	assert.Equal(t, "my-project", props.Project)
+	assert.Equal(t, "ec2", props.Service)
+	assert.Equal(t, "prod-cluster", props.Cluster)
+	assert.Equal(t, "worker-node-1", props.Name)
+	assert.Equal(t, "i-1234567890abcdef0", props.ProviderID)
+}
+
+// Test cloud cost properties
+func TestCloudCostProperties(t *testing.T) {
+	props := CloudCostProperties{
+		ProviderID:        "i-1234567890abcdef0",
+		Provider:          "aws",
+		AccountID:         "123456789",
+		AccountName:       "my-account",
+		InvoiceEntityID:   "entity-123",
+		InvoiceEntityName: "My Company",
+		RegionID:          "us-east-1",
+		AvailabilityZone:  "us-east-1a",
+		Service:           "ec2",
+		Category:          "compute",
+		Labels: map[string]string{
+			"environment": "production",
+			"team":        "platform",
+		},
+	}
+
+	assert.Equal(t, "i-1234567890abcdef0", props.ProviderID)
+	assert.Equal(t, "aws", props.Provider)
+	assert.Equal(t, "123456789", props.AccountID)
+	assert.Equal(t, "my-account", props.AccountName)
+	assert.Equal(t, "entity-123", props.InvoiceEntityID)
+	assert.Equal(t, "My Company", props.InvoiceEntityName)
+	assert.Equal(t, "us-east-1", props.RegionID)
+	assert.Equal(t, "us-east-1a", props.AvailabilityZone)
+	assert.Equal(t, "ec2", props.Service)
+	assert.Equal(t, "compute", props.Category)
+	assert.Equal(t, "production", props.Labels["environment"])
+	assert.Equal(t, "platform", props.Labels["team"])
+}
+
+// Test cost metric
+func TestCostMetric(t *testing.T) {
+	metric := CostMetric{
+		Cost:              100.0,
+		KubernetesPercent: 80.0,
+	}
+
+	assert.Equal(t, 100.0, metric.Cost)
+	assert.Equal(t, 80.0, metric.KubernetesPercent)
+}
+
+// Test time window
+func TestTimeWindow(t *testing.T) {
+	now := time.Now()
+	window := TimeWindow{
+		Start: now.Add(-24 * time.Hour),
+		End:   now,
+	}
+
+	assert.True(t, window.Start.Before(window.End))
+	assert.Equal(t, 24*time.Hour, window.End.Sub(window.Start))
+}
+
+// Test node overhead
+func TestNodeOverhead(t *testing.T) {
+	overhead := NodeOverhead{
+		RamOverheadFraction:  0.1,
+		CpuOverheadFraction:  0.05,
+		OverheadCostFraction: 0.15,
+	}
+
+	assert.Equal(t, 0.1, overhead.RamOverheadFraction)
+	assert.Equal(t, 0.05, overhead.CpuOverheadFraction)
+	assert.Equal(t, 0.15, overhead.OverheadCostFraction)
+}
+
+// Test asset breakdown
+func TestAssetBreakdown(t *testing.T) {
+	breakdown := AssetBreakdown{
+		Idle:   10.0,
+		Other:  5.0,
+		System: 15.0,
+		User:   70.0,
+	}
+
+	assert.Equal(t, 10.0, breakdown.Idle)
+	assert.Equal(t, 5.0, breakdown.Other)
+	assert.Equal(t, 15.0, breakdown.System)
+	assert.Equal(t, 70.0, breakdown.User)
+}
+
+// Test cloud cost summary
+func TestCloudCostSummary(t *testing.T) {
+	summary := CloudCostSummary{
+		TotalNetCost:       1000.0,
+		TotalAmortizedCost: 950.0,
+		TotalInvoicedCost:  1100.0,
+		KubernetesPercent:  85.0,
+		ProviderBreakdown: map[string]float64{
+			"aws": 800.0,
+			"gcp": 200.0,
+		},
+		ServiceBreakdown: map[string]float64{
+			"ec2": 600.0,
+			"s3":  200.0,
+			"rds": 200.0,
+		},
+		RegionBreakdown: map[string]float64{
+			"us-east-1": 600.0,
+			"us-west-2": 400.0,
+		},
+	}
+
+	assert.Equal(t, 1000.0, summary.TotalNetCost)
+	assert.Equal(t, 950.0, summary.TotalAmortizedCost)
+	assert.Equal(t, 1100.0, summary.TotalInvoicedCost)
+	assert.Equal(t, 85.0, summary.KubernetesPercent)
+	assert.Equal(t, 800.0, summary.ProviderBreakdown["aws"])
+	assert.Equal(t, 200.0, summary.ProviderBreakdown["gcp"])
+	assert.Equal(t, 600.0, summary.ServiceBreakdown["ec2"])
+	assert.Equal(t, 200.0, summary.ServiceBreakdown["s3"])
+	assert.Equal(t, 600.0, summary.RegionBreakdown["us-east-1"])
+	assert.Equal(t, 400.0, summary.RegionBreakdown["us-west-2"])
+}
+
+// Test default values
+func TestAllocationQueryDefaultValues(t *testing.T) {
+	query := AllocationQuery{}
+
+	// Test default values
+	assert.Equal(t, time.Duration(0), query.Step)
+	assert.False(t, query.Accumulate)
+	assert.False(t, query.ShareIdle)
+	assert.Empty(t, query.Aggregate)
+	assert.False(t, query.IncludeIdle)
+	assert.False(t, query.IdleByNode)
+	assert.False(t, query.IncludeProportionalAssetResourceCosts)
+	assert.False(t, query.IncludeAggregatedMetadata)
+	assert.False(t, query.ShareLB)
+}
+
+func TestCloudCostQueryDefaultValues(t *testing.T) {
+	query := CloudCostQuery{}
+
+	// Test default values
+	assert.Empty(t, query.Aggregate)
+	assert.Empty(t, query.Accumulate)
+	assert.Empty(t, query.Filter)
+	assert.Empty(t, query.Provider)
+	assert.Empty(t, query.Service)
+	assert.Empty(t, query.Category)
+	assert.Empty(t, query.Region)
+	assert.Empty(t, query.AccountID)
+}
+
+// Test edge cases
+func TestEdgeCases(t *testing.T) {
+	t.Run("zero duration step", func(t *testing.T) {
+		query := AllocationQuery{
+			Step: 0,
+		}
+		assert.Equal(t, time.Duration(0), query.Step)
+	})
+
+	t.Run("negative duration step", func(t *testing.T) {
+		query := AllocationQuery{
+			Step: -1 * time.Hour,
+		}
+		assert.Equal(t, -1*time.Hour, query.Step)
+	})
+
+	t.Run("very large duration step", func(t *testing.T) {
+		query := AllocationQuery{
+			Step: 365 * 24 * time.Hour, // 1 year
+		}
+		assert.Equal(t, 365*24*time.Hour, query.Step)
+	})
+
+	t.Run("empty aggregate string", func(t *testing.T) {
+		query := AllocationQuery{
+			Aggregate: "",
+		}
+		assert.Empty(t, query.Aggregate)
+	})
+
+	t.Run("comma separated aggregate", func(t *testing.T) {
+		query := AllocationQuery{
+			Aggregate: "namespace,cluster,node",
+		}
+		assert.Equal(t, "namespace,cluster,node", query.Aggregate)
+	})
+}
+
+// dummyQuerier captures the last QueryRequest it received
+type dummyQuerier struct {
+	last cloudcost.QueryRequest
+}
+
+func (dq *dummyQuerier) Query(_ context.Context, req cloudcost.QueryRequest) (*opencost.CloudCostSetRange, error) {
+	dq.last = req
+	// Return empty set range
+	ccsr, _ := opencost.NewCloudCostSetRange(time.Now().Add(-24*time.Hour), time.Now(), opencost.AccumulateOptionDay, "")
+	return ccsr, nil
+}
+
+func TestBuildCloudCostQueryRequest_AccumulateParsing(t *testing.T) {
+	s := &MCPServer{}
+	req := cloudcost.QueryRequest{}
+	params := &CloudCostQuery{
+		Aggregate:  "provider,service",
+		Accumulate: "week",
+	}
+	out := s.buildCloudCostQueryRequest(req, params)
+
+	assert.Equal(t, []string{"provider", "service"}, out.AggregateBy)
+	assert.NotEqual(t, opencost.AccumulateOptionNone, out.Accumulate)
+}
+
+func TestBuildCloudCostQueryRequest_FilterString(t *testing.T) {
+	s := &MCPServer{}
+	req := cloudcost.QueryRequest{}
+	params := &CloudCostQuery{
+		Filter: `provider:"gcp" and service:"Compute Engine"`,
+	}
+	out := s.buildCloudCostQueryRequest(req, params)
+	assert.NotNil(t, out.Filter)
+}
+
+func TestBuildFilterFromParams_SupportedFieldsOnly(t *testing.T) {
+	s := &MCPServer{}
+	params := &CloudCostQuery{
+		Provider:        "gcp",
+		ProviderID:      "cluster-1",
+		Service:         "Compute Engine",
+		Category:        "compute",
+		AccountID:       "acct-123",
+		InvoiceEntityID: "inv-456",
+		Region:          "us-central1", // intentionally set; ignored by builder
+		Labels: map[string]string{
+			"goog-k8s-cluster-name": "cluster-1",
+		},
+	}
+	f := s.buildFilterFromParams(params)
+	assert.NotNil(t, f)
+}
+
+func TestBuildFilterFromParams_LabelOnly(t *testing.T) {
+	s := &MCPServer{}
+	params := &CloudCostQuery{
+		Labels: map[string]string{"environment": "prod"},
+	}
+	f := s.buildFilterFromParams(params)
+	assert.NotNil(t, f)
+}
+
+func TestQueryCloudCosts_QuerierCapture(t *testing.T) {
+	dq := &dummyQuerier{}
+	s := &MCPServer{cloudQuerier: dq}
+
+	req := &OpenCostQueryRequest{
+		QueryType: CloudCostQueryType,
+		Window:    "5d",
+		CloudCostParams: &CloudCostQuery{
+			Aggregate:  "provider,service",
+			Accumulate: "week",
+			Provider:   "gcp",
+		},
+	}
+
+	_, err := s.QueryCloudCosts(req)
+	require.NoError(t, err)
+
+	assert.Equal(t, []string{"provider", "service"}, dq.last.AggregateBy)
+	assert.NotEqual(t, opencost.AccumulateOptionNone, dq.last.Accumulate)
+}
+
+// ---- Tests for MCP server end-to-end behavior ----
+
+func TestProcessMCPRequest_CloudCostDispatch(t *testing.T) {
+	dq := &dummyQuerier{}
+	s := &MCPServer{cloudQuerier: dq}
+
+	req := &MCPRequest{
+		Query: &OpenCostQueryRequest{
+			QueryType: CloudCostQueryType,
+			Window:    "3d",
+			CloudCostParams: &CloudCostQuery{
+				Aggregate:  "provider",
+				Accumulate: "day",
+				Provider:   "gcp",
+			},
+		},
+	}
+
+	resp, err := s.ProcessMCPRequest(req)
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.NotNil(t, resp.Data)
+}
+
+func TestProcessMCPRequest_UnsupportedType(t *testing.T) {
+	s := &MCPServer{}
+
+	req := &MCPRequest{
+		Query: &OpenCostQueryRequest{
+			QueryType: QueryType("unknown"),
+			Window:    "1d",
+		},
+	}
+	_, err := s.ProcessMCPRequest(req)
+	require.Error(t, err)
+}
+
+func TestProcessMCPRequest_ValidationError(t *testing.T) {
+	s := &MCPServer{}
+	// Missing window
+	req := &MCPRequest{
+		Query: &OpenCostQueryRequest{
+			QueryType: CloudCostQueryType,
+			Window:    "",
+		},
+	}
+	_, err := s.ProcessMCPRequest(req)
+	require.Error(t, err)
+}
+
+// ---- Additional comprehensive tests for missing functionality ----
+
+func TestNewMCPServer(t *testing.T) {
+	costModel := &costmodel.CostModel{}
+	provider := &mockProvider{}
+	cloudQuerier := &dummyQuerier{}
+
+	server := NewMCPServer(costModel, provider, cloudQuerier)
+
+	require.NotNil(t, server)
+	assert.Equal(t, costModel, server.costModel)
+	assert.Equal(t, provider, server.provider)
+	assert.Equal(t, cloudQuerier, server.cloudQuerier)
+}
+
+// Mock provider for testing
+type mockProvider struct{}
+
+func (mp *mockProvider) GetConfig() (*models.CustomPricing, error)                { return nil, nil }
+func (mp *mockProvider) AllNodePricing() (interface{}, error)                     { return nil, nil }
+func (mp *mockProvider) ClusterInfo() (map[string]string, error)                  { return nil, nil }
+func (mp *mockProvider) GetAddresses() ([]byte, error)                            { return nil, nil }
+func (mp *mockProvider) GetDisks() ([]byte, error)                                { return nil, nil }
+func (mp *mockProvider) GetOrphanedResources() ([]models.OrphanedResource, error) { return nil, nil }
+func (mp *mockProvider) NodePricing(models.Key) (*models.Node, models.PricingMetadata, error) {
+	return nil, models.PricingMetadata{}, nil
+}
+func (mp *mockProvider) GpuPricing(map[string]string) (string, error)            { return "", nil }
+func (mp *mockProvider) PVPricing(models.PVKey) (*models.PV, error)              { return nil, nil }
+func (mp *mockProvider) NetworkPricing() (*models.Network, error)                { return nil, nil }
+func (mp *mockProvider) LoadBalancerPricing() (*models.LoadBalancer, error)      { return nil, nil }
+func (mp *mockProvider) DownloadPricingData() error                              { return nil }
+func (mp *mockProvider) GetKey(map[string]string, *clustercache.Node) models.Key { return nil }
+func (mp *mockProvider) GetPVKey(*clustercache.PersistentVolume, map[string]string, string) models.PVKey {
+	return nil
+}
+func (mp *mockProvider) UpdateConfig(io.Reader, string) (*models.CustomPricing, error) {
+	return nil, nil
+}
+func (mp *mockProvider) UpdateConfigFromConfigMap(map[string]string) (*models.CustomPricing, error) {
+	return nil, nil
+}
+func (mp *mockProvider) GetManagementPlatform() (string, error)                         { return "", nil }
+func (mp *mockProvider) ApplyReservedInstancePricing(map[string]*models.Node)           {}
+func (mp *mockProvider) ServiceAccountStatus() *models.ServiceAccountStatus             { return nil }
+func (mp *mockProvider) PricingSourceStatus() map[string]*models.PricingSource          { return nil }
+func (mp *mockProvider) ClusterManagementPricing() (string, float64, error)             { return "", 0, nil }
+func (mp *mockProvider) CombinedDiscountForNode(string, bool, float64, float64) float64 { return 0 }
+func (mp *mockProvider) Regions() []string                                              { return nil }
+func (mp *mockProvider) PricingSourceSummary() interface{}                              { return nil }
+
+func TestGenerateQueryID(t *testing.T) {
+	// Test that generateQueryID returns a non-empty string
+	id1 := generateQueryID()
+	id2 := generateQueryID()
+
+	assert.NotEmpty(t, id1)
+	assert.NotEmpty(t, id2)
+	assert.NotEqual(t, id1, id2) // Should be different each time
+	assert.Contains(t, id1, "query-")
+}
+
+func TestTransformAllocationSet_NilInput(t *testing.T) {
+	result := transformAllocationSet(nil)
+
+	require.NotNil(t, result)
+	assert.NotNil(t, result.Allocations)
+	assert.Len(t, result.Allocations, 0)
+}
+
+func TestTransformAllocationSet_EmptyInput(t *testing.T) {
+	emptySet := &opencost.AllocationSet{
+		Allocations: map[string]*opencost.Allocation{},
+	}
+
+	result := transformAllocationSet(emptySet)
+
+	require.NotNil(t, result)
+	assert.Contains(t, result.Allocations, "allocations")
+	assert.Len(t, result.Allocations["allocations"].Allocations, 0)
+}
+
+func TestTransformAssetSet_NilInput(t *testing.T) {
+	result := transformAssetSet(nil)
+
+	require.NotNil(t, result)
+	assert.NotNil(t, result.Assets)
+	assert.Len(t, result.Assets, 0)
+}
+
+func TestTransformAssetSet_EmptyInput(t *testing.T) {
+	emptySet := &opencost.AssetSet{
+		Assets: map[string]opencost.Asset{},
+	}
+
+	result := transformAssetSet(emptySet)
+
+	require.NotNil(t, result)
+	assert.Contains(t, result.Assets, "assets")
+	assert.Len(t, result.Assets["assets"].Assets, 0)
+}
+
+func TestBuildFilterFromParams_EmptyParams(t *testing.T) {
+	s := &MCPServer{}
+	params := &CloudCostQuery{}
+
+	filter := s.buildFilterFromParams(params)
+	assert.Nil(t, filter)
+}
+
+func TestBuildFilterFromParams_RegionIgnored(t *testing.T) {
+	s := &MCPServer{}
+	params := &CloudCostQuery{
+		Region: "us-east-1", // Should be ignored
+	}
+
+	filter := s.buildFilterFromParams(params)
+	assert.Nil(t, filter) // Should return nil since only region is set
+}
+
+func TestBuildFilterFromParams_EmptyLabelKey(t *testing.T) {
+	s := &MCPServer{}
+	params := &CloudCostQuery{
+		Labels: map[string]string{
+			"":      "value1", // Empty key should be ignored
+			"valid": "value2",
+		},
+	}
+
+	filter := s.buildFilterFromParams(params)
+	assert.NotNil(t, filter)
+}
+
+func TestBuildCloudCostQueryRequest_EmptyParams(t *testing.T) {
+	s := &MCPServer{}
+	req := cloudcost.QueryRequest{}
+	params := &CloudCostQuery{}
+
+	result := s.buildCloudCostQueryRequest(req, params)
+
+	assert.Equal(t, req, result) // Should return unchanged request
+}
+
+func TestBuildCloudCostQueryRequest_InvalidFilterString(t *testing.T) {
+	s := &MCPServer{}
+	req := cloudcost.QueryRequest{}
+	params := &CloudCostQuery{
+		Filter: "invalid filter syntax !!!",
+	}
+
+	result := s.buildCloudCostQueryRequest(req, params)
+
+	// Should not panic and should return request with nil filter
+	assert.Nil(t, result.Filter)
+}
+
+func TestQueryCloudCosts_NilCloudQuerier(t *testing.T) {
+	s := &MCPServer{cloudQuerier: nil}
+
+	req := &OpenCostQueryRequest{
+		QueryType: CloudCostQueryType,
+		Window:    "24h",
+	}
+
+	_, err := s.QueryCloudCosts(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "cloud cost querier not configured")
+}
+
+func TestQueryCloudCosts_InvalidWindow(t *testing.T) {
+	s := &MCPServer{cloudQuerier: &dummyQuerier{}}
+
+	req := &OpenCostQueryRequest{
+		QueryType: CloudCostQueryType,
+		Window:    "invalid-window",
+	}
+
+	_, err := s.QueryCloudCosts(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "failed to parse window")
+}
+
+func TestQueryAssets_InvalidWindow(t *testing.T) {
+	s := &MCPServer{}
+
+	req := &OpenCostQueryRequest{
+		QueryType: AssetQueryType,
+		Window:    "invalid-window",
+	}
+
+	_, err := s.QueryAssets(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "failed to parse window")
+}
+
+func TestQueryAllocations_InvalidWindow(t *testing.T) {
+	s := &MCPServer{}
+
+	req := &OpenCostQueryRequest{
+		QueryType: AllocationQueryType,
+		Window:    "invalid-window",
+	}
+
+	_, err := s.QueryAllocations(req)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "failed to parse window")
+}
+
+
+func TestProcessMCPRequest_ResponseMetadata(t *testing.T) {
+	dq := &dummyQuerier{}
+	s := &MCPServer{cloudQuerier: dq}
+
+	req := &MCPRequest{
+		Query: &OpenCostQueryRequest{
+			QueryType: CloudCostQueryType,
+			Window:    "1h",
+		},
+	}
+
+	resp, err := s.ProcessMCPRequest(req)
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+
+	// Check response metadata
+	assert.NotEmpty(t, resp.QueryInfo.QueryID)
+	assert.NotZero(t, resp.QueryInfo.Timestamp)
+	assert.Greater(t, resp.QueryInfo.ProcessingTime, time.Duration(0))
+}
+
+func TestCloudCostQuery_NewFields(t *testing.T) {
+	query := CloudCostQuery{
+		InvoiceEntityID: "entity-123",
+		ProviderID:      "provider-456",
+		Labels: map[string]string{
+			"environment": "prod",
+			"team":        "platform",
+		},
+	}
+
+	assert.Equal(t, "entity-123", query.InvoiceEntityID)
+	assert.Equal(t, "provider-456", query.ProviderID)
+	assert.Equal(t, "prod", query.Labels["environment"])
+	assert.Equal(t, "platform", query.Labels["team"])
+}

+ 7 - 0
tilt-values.yaml

@@ -5,6 +5,8 @@ service:
   enabled: true
   enabled: true
   # --  Kubernetes Service type
   # --  Kubernetes Service type
   type: ClusterIP
   type: ClusterIP
+  # -- Additional ports for the service
+  extraPorts: []
 
 
 opencost:
 opencost:
   exporter:
   exporter:
@@ -12,6 +14,11 @@ opencost:
     cloudProviderApiKey: ""
     cloudProviderApiKey: ""
     # -- Default cluster ID to use if cluster_id is not set in Prometheus metrics.
     # -- Default cluster ID to use if cluster_id is not set in Prometheus metrics.
     defaultClusterId: "tilt-cluster"
     defaultClusterId: "tilt-cluster"
+    # -- Extra container ports for MCP server
+    extraPorts:
+      - name: mcp-server
+        containerPort: 8081
+        protocol: TCP
   livenessProbe:
   livenessProbe:
     # -- Whether probe is enabled
     # -- Whether probe is enabled
     enabled: true
     enabled: true