diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5530e2..8a3b5ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28290f1..492c234 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index a49827e..834415e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " diff --git a/Makefile b/Makefile index 1f095f6..99940c9 100644 --- a/Makefile +++ b/Makefile @@ -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: @@ -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) diff --git a/cmd/cli/mcp_command.go b/cmd/cli/mcp_command.go index e4552dd..032ea5d 100644 --- a/cmd/cli/mcp_command.go +++ b/cmd/cli/mcp_command.go @@ -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" ) @@ -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 -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index d699377..1878d0f 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index a782f83..008857a 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 4277096..9c61e11 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -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 @@ -46,59 +37,35 @@ 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) } } } @@ -106,41 +73,15 @@ func (s *Server) parseGetCostAndUsageParams(args map[string]interface{}) (types. 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 } - diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 87ada5d..f7b5fc1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1,28 +1,27 @@ package mcp import ( + "context" "log/slog" "github.com/cduggn/ccexplorer/internal/ports" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // Server wraps the MCP server with ccExplorer-specific functionality type Server struct { - mcpServer *server.MCPServer + mcpServer *mcp.Server awsService ports.AWSService } // NewServer creates a new MCP server instance for stdio transport func NewServer(awsService ports.AWSService) *Server { slog.Info("Creating new ccExplorer MCP server") - - mcpServer := server.NewMCPServer( - "ccExplorer MCP Server", - "1.0.0", - server.WithToolCapabilities(true), - ) + + mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: "ccExplorer MCP Server", + Version: "1.0.0", + }, nil) return &Server{ mcpServer: mcpServer, @@ -33,27 +32,19 @@ func NewServer(awsService ports.AWSService) *Server { // RegisterTools registers all available MCP tools with the server func (s *Server) RegisterTools() error { slog.Info("Registering MCP tools") - - // Register the get_cost_and_usage tool - getCostTool := mcp.NewTool("get_cost_and_usage", - mcp.WithDescription("Query AWS Cost Explorer for cost and usage data"), - mcp.WithString("start_date", mcp.Required()), - mcp.WithString("end_date", mcp.Required()), - mcp.WithString("granularity", mcp.Enum("DAILY", "MONTHLY", "HOURLY")), - mcp.WithString("metrics"), - mcp.WithString("group_by"), - mcp.WithString("filter_by_service"), - mcp.WithBoolean("exclude_discounts"), - ) - - // Add tool to the MCP server with our handler - s.mcpServer.AddTool(getCostTool, s.handleGetCostAndUsage) + + // Register the get_cost_and_usage tool with typed handler + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "get_cost_and_usage", + Description: "Query AWS Cost Explorer for cost and usage data", + }, s.handleGetCostAndUsage) + slog.Info("Successfully registered get_cost_and_usage tool") - + return nil } -// MCPServer returns the underlying MCP server for stdio transport -func (s *Server) MCPServer() *server.MCPServer { - return s.mcpServer -} \ No newline at end of file +// Run starts the MCP server with the given transport +func (s *Server) Run(ctx context.Context) error { + return s.mcpServer.Run(ctx, &mcp.StdioTransport{}) +} diff --git a/internal/mcp/translator_test.go b/internal/mcp/translator_test.go index 4b2b6de..d8266d3 100644 --- a/internal/mcp/translator_test.go +++ b/internal/mcp/translator_test.go @@ -8,20 +8,17 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseGetCostAndUsageParams(t *testing.T) { - server := &Server{} - +func TestInputToParams(t *testing.T) { tests := []struct { name string - args map[string]interface{} + input types.GetCostAndUsageInput expected types.MCPToolParameters - wantErr bool }{ { - name: "valid basic parameters", - args: map[string]interface{}{ - "start_date": "2024-01-01", - "end_date": "2024-01-31", + name: "basic parameters with defaults", + input: types.GetCostAndUsageInput{ + StartDate: "2024-01-01", + EndDate: "2024-01-31", }, expected: types.MCPToolParameters{ StartDate: "2024-01-01", @@ -29,64 +26,50 @@ func TestParseGetCostAndUsageParams(t *testing.T) { Granularity: "MONTHLY", Metrics: []string{"UnblendedCost"}, }, - wantErr: false, }, { - name: "valid parameters with all options", - args: map[string]interface{}{ - "start_date": "2024-01-01", - "end_date": "2024-01-31", - "granularity": "DAILY", - "metrics": []interface{}{"AmortizedCost", "BlendedCost"}, - "group_by": []interface{}{"SERVICE", "TAG:Project"}, - "filter_by_service": "Amazon Simple Storage Service", - "exclude_discounts": true, + name: "all options specified", + input: types.GetCostAndUsageInput{ + StartDate: "2024-01-01", + EndDate: "2024-01-31", + Granularity: "DAILY", + Metrics: "AmortizedCost, BlendedCost", + GroupBy: "SERVICE, TAG:Project", + FilterByService: "Amazon Simple Storage Service", + ExcludeDiscounts: true, }, expected: types.MCPToolParameters{ - StartDate: "2024-01-01", - EndDate: "2024-01-31", - Granularity: "DAILY", - Metrics: []string{"AmortizedCost", "BlendedCost"}, - GroupBy: []string{"SERVICE", "TAG:Project"}, - FilterByService: "Amazon Simple Storage Service", - ExcludeDiscounts: true, + StartDate: "2024-01-01", + EndDate: "2024-01-31", + Granularity: "DAILY", + Metrics: []string{"AmortizedCost", "BlendedCost"}, + GroupBy: []string{"SERVICE", "TAG:Project"}, + FilterByService: "Amazon Simple Storage Service", + ExcludeDiscounts: true, }, - wantErr: false, }, { - name: "missing start_date", - args: map[string]interface{}{ - "end_date": "2024-01-31", - }, - wantErr: true, - }, - { - name: "missing end_date", - args: map[string]interface{}{ - "start_date": "2024-01-01", + name: "single metric and group_by", + input: types.GetCostAndUsageInput{ + StartDate: "2024-01-01", + EndDate: "2024-01-31", + Granularity: "MONTHLY", + Metrics: "UnblendedCost", + GroupBy: "SERVICE", }, - wantErr: true, - }, - { - name: "invalid start_date type", - args: map[string]interface{}{ - "start_date": 123, - "end_date": "2024-01-31", + expected: types.MCPToolParameters{ + StartDate: "2024-01-01", + EndDate: "2024-01-31", + Granularity: "MONTHLY", + Metrics: []string{"UnblendedCost"}, + GroupBy: []string{"SERVICE"}, }, - wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := server.parseGetCostAndUsageParams(tt.args) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) + result := inputToParams(tt.input) assert.Equal(t, tt.expected.StartDate, result.StartDate) assert.Equal(t, tt.expected.EndDate, result.EndDate) assert.Equal(t, tt.expected.Granularity, result.Granularity) @@ -253,4 +236,4 @@ func TestValidateInternalRequest(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/pinecone/pinecone.go b/internal/pinecone/pinecone.go index fdc3ade..36afb25 100644 --- a/internal/pinecone/pinecone.go +++ b/internal/pinecone/pinecone.go @@ -133,7 +133,7 @@ func (p *ClientAPI) sendRequest(req *http.Request, v any) error { return fmt.Errorf("failed to send request: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode < http.StatusOK || res.StatusCode >= http. StatusBadRequest { return fmt.Errorf("unexpected status code %d", res.StatusCode) diff --git a/internal/types/mcp_types.go b/internal/types/mcp_types.go index 3f6da25..c0cfec3 100644 --- a/internal/types/mcp_types.go +++ b/internal/types/mcp_types.go @@ -1,41 +1,27 @@ package types -// MCPRequest represents the incoming MCP tool request -type MCPRequest struct { - ToolName string `json:"tool_name"` - Parameters MCPToolParameters `json:"parameters"` +// GetCostAndUsageInput is the typed input for the get_cost_and_usage MCP tool. +// The SDK auto-unmarshals JSON into this struct and infers the JSON schema from tags. +type GetCostAndUsageInput struct { + StartDate string `json:"start_date" jsonschema:"required,description=Start date in YYYY-MM-DD format"` + EndDate string `json:"end_date" jsonschema:"required,description=End date in YYYY-MM-DD format"` + Granularity string `json:"granularity" jsonschema:"enum=DAILY,enum=MONTHLY,enum=HOURLY,description=Query granularity"` + Metrics string `json:"metrics" jsonschema:"description=Cost metrics (comma-separated)"` + GroupBy string `json:"group_by" jsonschema:"description=Group by dimensions (comma-separated)"` + FilterByService string `json:"filter_by_service" jsonschema:"description=Filter by AWS service name"` + ExcludeDiscounts bool `json:"exclude_discounts" jsonschema:"description=Exclude discount line items"` } -// MCPToolParameters represents the parameters for get_cost_and_usage tool +// MCPToolParameters represents the parsed parameters for get_cost_and_usage tool type MCPToolParameters struct { - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` - Granularity string `json:"granularity"` - Metrics []string `json:"metrics"` - GroupBy []string `json:"group_by"` - FilterByService string `json:"filter_by_service,omitempty"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Granularity string `json:"granularity"` + Metrics []string `json:"metrics"` + GroupBy []string `json:"group_by"` + FilterByService string `json:"filter_by_service,omitempty"` FilterByDimension map[string]string `json:"filter_by_dimension,omitempty"` FilterByTag map[string]string `json:"filter_by_tag,omitempty"` - ExcludeDiscounts bool `json:"exclude_discounts,omitempty"` -} - -// MCPResponse represents the MCP tool response -type MCPResponse struct { - Content []MCPContent `json:"content"` - IsError bool `json:"isError,omitempty"` -} - -// MCPContent represents the content within an MCP response -type MCPContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Data interface{} `json:"data,omitempty"` -} - -// MCPError represents an MCP error response -type MCPError struct { - Error string `json:"error"` - Code int `json:"code,omitempty"` - Message string `json:"message,omitempty"` + ExcludeDiscounts bool `json:"exclude_discounts,omitempty"` } diff --git a/internal/writer/renderers.go b/internal/writer/renderers.go index c1abcbd..77f257f 100644 --- a/internal/writer/renderers.go +++ b/internal/writer/renderers.go @@ -200,7 +200,7 @@ func (r *CSVRenderer) Render(data *CSVOutput) error { if err != nil { return types.Error{Msg: "Error creating CSV file: " + err.Error()} } - defer file.Close() + defer func() { _ = file.Close() }() writer := csv.NewWriter(file) defer writer.Flush() @@ -234,7 +234,7 @@ func (r *ChartRenderer) Render(data *ChartOutput) error { if err != nil { return types.Error{Msg: "Failed creating chart HTML file: " + err.Error()} } - defer file.Close() + defer func() { _ = file.Close() }() if err := data.Page.Render(io.MultiWriter(file)); err != nil { return err