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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
cache: true

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v7
with:
version: latest
version: v2.8.0
args: --timeout=5m
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.22.0-alpine3.19 as build-stage
FROM golang:1.25-alpine as build-stage

# Add Maintainer Info
LABEL maintainer="Colin Duggan <duggan.colin@gmail.com>"
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ $(GORELEASER):
go install github.com/goreleaser/goreleaser@latest

$(GOLANGCI_LINT):
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0

.PHONY: setup
setup:
Expand All @@ -24,7 +24,7 @@ test:

.PHONY: lint
lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT) run --color=always --sort-results ./...
$(GOLANGCI_LINT) run --color=always ./...

.PHONY: lint-exp
lint-exp: $(GOLANGCI_LINT)
Expand Down
8 changes: 4 additions & 4 deletions cmd/cli/mcp_command.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package cli

import (
"context"
"fmt"
"log/slog"

"github.com/cduggn/ccexplorer/internal/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -61,10 +61,10 @@ func runMCPServe(cmd *cobra.Command, args []string) error {

slog.Info("MCP tools registered successfully, starting stdio server")

// Start MCP server with stdio transport (recommended for MCP clients)
if err := server.ServeStdio(mcpServer.MCPServer()); err != nil {
// Start MCP server with stdio transport
if err := mcpServer.Run(context.Background()); err != nil {
return fmt.Errorf("MCP stdio server error: %w", err)
}

return nil
}
}
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/cduggn/ccexplorer

go 1.24
go 1.25

require (
github.com/aws/aws-sdk-go-v2 v1.32.7
Expand All @@ -9,7 +9,7 @@ require (
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be
github.com/go-echarts/go-echarts/v2 v2.4.6
github.com/jedib0t/go-pretty/v6 v6.5.8
github.com/mark3labs/mcp-go v0.32.0
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/sashabaranov/go-openai v1.30.3
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
Expand All @@ -30,7 +30,7 @@ require (
github.com/aws/smithy-go v1.22.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
Expand All @@ -45,11 +45,11 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
Expand Down
19 changes: 12 additions & 7 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-echarts/go-echarts/v2 v2.4.6 h1:fBrN2KNe0KTM8wLsysIUVbb0vwZJ+Z6TOXGMiiv+po4=
github.com/go-echarts/go-echarts/v2 v2.4.6/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand All @@ -58,12 +60,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down Expand Up @@ -96,7 +98,6 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
Expand All @@ -111,10 +112,14 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
125 changes: 33 additions & 92 deletions internal/mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,26 @@ import (

"github.com/cduggn/ccexplorer/internal/types"
"github.com/cduggn/ccexplorer/internal/utils"
"github.com/mark3labs/mcp-go/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// handleGetCostAndUsage handles the get_cost_and_usage MCP tool call
func (s *Server) handleGetCostAndUsage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
slog.Info("Handling get_cost_and_usage request", "arguments", request.Params.Arguments)
func (s *Server) handleGetCostAndUsage(ctx context.Context, req *mcp.CallToolRequest, input types.GetCostAndUsageInput) (*mcp.CallToolResult, any, error) {
slog.Info("Handling get_cost_and_usage request", "input", input)

// Type assert the arguments
args, ok := request.Params.Arguments.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid arguments type")
}

// Parse and validate arguments
mcpParams, err := s.parseGetCostAndUsageParams(args)
if err != nil {
return nil, fmt.Errorf("invalid parameters: %w", err)
}
// Convert typed input to internal MCPToolParameters
mcpParams := inputToParams(input)

// Translate MCP parameters to internal request type
internalRequest, err := s.translateMCPToInternalRequest(mcpParams)
if err != nil {
return nil, fmt.Errorf("failed to translate parameters: %w", err)
return nil, nil, fmt.Errorf("failed to translate parameters: %w", err)
}

// Call AWS Cost Explorer service
result, err := s.awsService.GetCostAndUsage(ctx, internalRequest)
if err != nil {
return nil, fmt.Errorf("AWS service error: %w", err)
return nil, nil, fmt.Errorf("AWS service error: %w", err)
}

// Transform the response to a format suitable for MCP
Expand All @@ -46,101 +37,51 @@ func (s *Server) handleGetCostAndUsage(ctx context.Context, request mcp.CallTool
// Convert to JSON for MCP response
responseJSON, err := json.MarshalIndent(response, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}

// Return MCP tool result
return mcp.NewToolResultText(string(responseJSON)), nil
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(responseJSON)}},
}, nil, nil
}

// parseGetCostAndUsageParams parses the MCP tool arguments into MCPToolParameters
func (s *Server) parseGetCostAndUsageParams(args map[string]interface{}) (types.MCPToolParameters, error) {
var params types.MCPToolParameters

// Required parameters
if startDate, ok := args["start_date"].(string); ok {
params.StartDate = startDate
} else {
return params, fmt.Errorf("start_date is required and must be a string")
}

if endDate, ok := args["end_date"].(string); ok {
params.EndDate = endDate
} else {
return params, fmt.Errorf("end_date is required and must be a string")
// inputToParams converts the typed SDK input to internal MCPToolParameters
func inputToParams(input types.GetCostAndUsageInput) types.MCPToolParameters {
params := types.MCPToolParameters{
StartDate: input.StartDate,
EndDate: input.EndDate,
Granularity: input.Granularity,
FilterByService: input.FilterByService,
ExcludeDiscounts: input.ExcludeDiscounts,
}

// Optional parameters with defaults
if granularity, ok := args["granularity"].(string); ok {
params.Granularity = granularity
} else {
if params.Granularity == "" {
params.Granularity = "MONTHLY"
}

// Parse metrics - can be array or comma-separated string
if metricsInterface, ok := args["metrics"]; ok {
if metricsArray, ok := metricsInterface.([]interface{}); ok {
// Handle array format (typically from HTTP JSON-RPC)
for _, metric := range metricsArray {
if metricStr, ok := metric.(string); ok {
params.Metrics = append(params.Metrics, metricStr)
}
}
} else if metricsStr, ok := metricsInterface.(string); ok && metricsStr != "" {
// Handle string format (typically from stdio)
if strings.Contains(metricsStr, ",") {
// Comma-separated values
for _, metric := range strings.Split(metricsStr, ",") {
metric = strings.TrimSpace(metric)
if metric != "" {
params.Metrics = append(params.Metrics, metric)
}
}
} else {
// Single metric
params.Metrics = append(params.Metrics, metricsStr)
// Parse comma-separated metrics
if input.Metrics != "" {
for _, m := range strings.Split(input.Metrics, ",") {
m = strings.TrimSpace(m)
if m != "" {
params.Metrics = append(params.Metrics, m)
}
}
}
if len(params.Metrics) == 0 {
params.Metrics = []string{"UnblendedCost"}
}

// Parse group_by - can be array or comma-separated string
if groupByInterface, ok := args["group_by"]; ok {
if groupByArray, ok := groupByInterface.([]interface{}); ok {
// Handle array format (typically from HTTP JSON-RPC)
for _, groupBy := range groupByArray {
if groupByStr, ok := groupBy.(string); ok {
params.GroupBy = append(params.GroupBy, groupByStr)
}
}
} else if groupByStr, ok := groupByInterface.(string); ok && groupByStr != "" {
// Handle string format (typically from stdio)
if strings.Contains(groupByStr, ",") {
// Comma-separated values
for _, groupBy := range strings.Split(groupByStr, ",") {
groupBy = strings.TrimSpace(groupBy)
if groupBy != "" {
params.GroupBy = append(params.GroupBy, groupBy)
}
}
} else {
// Single group_by
params.GroupBy = append(params.GroupBy, groupByStr)
// Parse comma-separated group_by
if input.GroupBy != "" {
for _, g := range strings.Split(input.GroupBy, ",") {
g = strings.TrimSpace(g)
if g != "" {
params.GroupBy = append(params.GroupBy, g)
}
}
}

// Optional filter parameters
if filterService, ok := args["filter_by_service"].(string); ok && filterService != "" {
params.FilterByService = filterService
}

if excludeDiscounts, ok := args["exclude_discounts"].(bool); ok {
params.ExcludeDiscounts = excludeDiscounts
}

return params, nil
return params
}

Loading
Loading