diff --git a/pkg/client/types.go b/pkg/client/types.go index 3073dde..300d275 100644 --- a/pkg/client/types.go +++ b/pkg/client/types.go @@ -446,3 +446,60 @@ type WorkspaceToolResponse struct { Variables []*tfe.Variable `jsonapi:"polyrelation,variables,omitempty"` Readme string `jsonapi:"attr,readme,omitempty"` } + +// WorkspaceStateAndConfigResponse represents the response for fetching both state and configuration +type WorkspaceStateAndConfigResponse struct { + Type string `jsonapi:"primary,tool"` + Success bool `jsonapi:"attr,success"` + Workspace *tfe.Workspace `jsonapi:"attr,workspace,omitempty"` + StateData *StateData `jsonapi:"attr,state_data,omitempty"` + ConfigData *ConfigurationData `jsonapi:"attr,config_data,omitempty"` + Metadata *ResponseMetadata `jsonapi:"attr,metadata,omitempty"` +} + +// StateData contains the raw Terraform state file content +type StateData struct { + StateFileContent map[string]interface{} `json:"state_file_content"` +} + +// ConfigurationData contains current Terraform configuration information +type ConfigurationData struct { + ConfigVersionID string `json:"config_version_id,omitempty"` + Status string `json:"status,omitempty"` + Source string `json:"source,omitempty"` + ConfigContent string `json:"config_content,omitempty"` +} + +// ResponseMetadata contains metadata about the response +type ResponseMetadata struct { + RetrievedAt time.Time `json:"retrieved_at"` + WorkspaceID string `json:"workspace_id"` + OrganizationName string `json:"organization_name"` + WorkspaceName string `json:"workspace_name"` +} + +// WorkspaceInfo represents basic workspace information that can be JSON serialized +type WorkspaceInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Environment string `json:"environment,omitempty"` + AutoApply bool `json:"auto_apply"` + TerraformVersion string `json:"terraform_version,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty"` + ResourceCount int `json:"resource_count"` + ApplyDurationAverage int64 `json:"apply_duration_average,omitempty"` + PlanDurationAverage int64 `json:"plan_duration_average,omitempty"` +} + +// StateAndConfigJSONResponse represents the JSON response for fetching both state and configuration +// This uses regular JSON tags instead of JSONAPI tags to preserve raw state content +type StateAndConfigJSONResponse struct { + Type string `json:"type"` + Success bool `json:"success"` + Workspace *WorkspaceInfo `json:"workspace,omitempty"` + TfStateFileContent map[string]interface{} `json:"tf_state_file_content,omitempty"` + ConfigData *ConfigurationData `json:"config_data,omitempty"` + Metadata *ResponseMetadata `json:"metadata,omitempty"` +} diff --git a/pkg/tools/tfe/get_tf_state_and_config.go b/pkg/tools/tfe/get_tf_state_and_config.go new file mode 100644 index 0000000..916e068 --- /dev/null +++ b/pkg/tools/tfe/get_tf_state_and_config.go @@ -0,0 +1,232 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-mcp-server/pkg/client" + "github.com/hashicorp/terraform-mcp-server/pkg/utils" + log "github.com/sirupsen/logrus" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// GetTfStateAndConfig creates a tool to fetch Terraform state for a workspace +func GetTfStateAndConfig(logger *log.Logger) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("get_tf_state_and_config", + mcp.WithDescription(`Fetches the current Terraform state for a workspace. Downloads the complete state file and provides raw state content without manipulation, suitable for comprehensive infrastructure analysis.`), + mcp.WithTitleAnnotation("Get Terraform state for a workspace"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithString("terraform_org_name", + mcp.Required(), + mcp.Description("The Terraform Cloud/Enterprise organization name"), + ), + mcp.WithString("workspace_name", + mcp.Required(), + mcp.Description("The name of the workspace to fetch state for"), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return getTfStateAndConfigHandler(ctx, request, logger) + }, + } +} + +func getTfStateAndConfigHandler(ctx context.Context, request mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { + // Get required parameters + terraformOrgName, err := request.RequireString("terraform_org_name") + if err != nil { + return nil, utils.LogAndReturnError(logger, "The 'terraform_org_name' parameter is required", err) + } + terraformOrgName = strings.TrimSpace(terraformOrgName) + + workspaceName, err := request.RequireString("workspace_name") + if err != nil { + return nil, utils.LogAndReturnError(logger, "The 'workspace_name' parameter is required", err) + } + workspaceName = strings.TrimSpace(workspaceName) + + // Get a Terraform client from context + tfeClient, err := client.GetTfeClientFromContext(ctx, logger) + if err != nil { + return nil, utils.LogAndReturnError(logger, "getting Terraform client - please ensure TFE_TOKEN and TFE_ADDRESS are properly configured", err) + } + + // Fetch workspace details + workspace, err := tfeClient.Workspaces.Read(ctx, terraformOrgName, workspaceName) + if err != nil { + return nil, utils.LogAndReturnError(logger, "reading workspace details", err) + } + + // Convert workspace to JSON-serializable format + workspaceInfo := &client.WorkspaceInfo{ + ID: workspace.ID, + Name: workspace.Name, + Description: workspace.Description, + Environment: workspace.Environment, + AutoApply: workspace.AutoApply, + TerraformVersion: workspace.TerraformVersion, + WorkingDirectory: workspace.WorkingDirectory, + ExecutionMode: string(workspace.ExecutionMode), + ResourceCount: workspace.ResourceCount, + ApplyDurationAverage: int64(workspace.ApplyDurationAverage), + PlanDurationAverage: int64(workspace.PlanDurationAverage), + } + + // Build response with metadata using JSON-friendly struct + response := &client.StateAndConfigJSONResponse{ + Type: "get_tf_state_and_config", + Success: true, + Workspace: workspaceInfo, + Metadata: &client.ResponseMetadata{ + RetrievedAt: time.Now(), + WorkspaceID: workspace.ID, + OrganizationName: terraformOrgName, + WorkspaceName: workspaceName, + }, + } + + // Fetch state data + stateContent, err := fetchStateContent(ctx, tfeClient, workspace.ID, logger) + if err != nil { + logger.WithError(err).Warn("failed to fetch state data, continuing without it") + } else { + response.TfStateFileContent = stateContent + } + + + + // Debug: Log what we're about to return + if response.TfStateFileContent != nil { + logger.WithFields(log.Fields{ + "tf_state_file_content_keys": getMapKeys(response.TfStateFileContent), + "has_tf_state_file_content": response.TfStateFileContent != nil, + }).Info("Response state content summary") + } + + // Debug: Log response preparation + logger.WithFields(log.Fields{ + "has_tf_state_content": response.TfStateFileContent != nil, + "workspace_id": response.Workspace.ID, + }).Info("Final structured response prepared") + + return mcp.NewToolResultStructuredOnly(response), nil +} + +// fetchStateContent retrieves and parses the complete Terraform state file +func fetchStateContent(ctx context.Context, tfeClient *tfe.Client, workspaceID string, logger *log.Logger) (map[string]interface{}, error) { + // Get current state version + stateVersion, err := tfeClient.StateVersions.ReadCurrent(ctx, workspaceID) + if err != nil { + return nil, utils.LogAndReturnError(logger, "reading current state version", err) + } + + // Download and parse the state file for raw content + if stateVersion.JSONDownloadURL != "" { + stateFileContent, err := downloadStateFile(ctx, stateVersion.JSONDownloadURL, logger) + if err != nil { + return nil, utils.LogAndReturnError(logger, "failed to download state file", err) + } + + return stateFileContent, nil + } + + return nil, utils.LogAndReturnError(logger, "no state file download URL available", nil) +} + + + +// downloadStateFile downloads the Terraform state file and returns it as raw JSON content +func downloadStateFile(ctx context.Context, downloadURL string, logger *log.Logger) (map[string]interface{}, error) { + logger.Info("Downloading Terraform state file") + + // Get the token from environment (same way the TFE client was created) + terraformToken := utils.GetEnv("TFE_TOKEN", "") + if terraformToken == "" { + return nil, fmt.Errorf("TFE_TOKEN environment variable is required for downloading state files") + } + + // Create HTTP request with authorization + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + // Add authorization header + req.Header.Set("Authorization", "Bearer "+terraformToken) + + // Make the request using standard HTTP client + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading state file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download state file: status %d", resp.StatusCode) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading state file content: %w", err) + } + + // Parse the JSON state file into a map for raw content + var stateContent map[string]interface{} + if err := json.Unmarshal(body, &stateContent); err != nil { + return nil, fmt.Errorf("parsing state file JSON: %w", err) + } + + // Log detailed information about what we received + resourcesCount := 0 + outputsCount := 0 + + if resources, ok := stateContent["resources"].([]interface{}); ok { + resourcesCount = len(resources) + } + if outputs, ok := stateContent["outputs"].(map[string]interface{}); ok { + outputsCount = len(outputs) + } + + logger.WithFields(log.Fields{ + "state_size_bytes": len(body), + "top_level_keys": getMapKeys(stateContent), + "resources_count": resourcesCount, + "outputs_count": outputsCount, + "terraform_version": stateContent["terraform_version"], + "format_version": stateContent["version"], + "serial": stateContent["serial"], + "lineage": stateContent["lineage"], + }).Info("Successfully downloaded and parsed Terraform state file") + + // Debug: Log raw content size and structure for troubleshooting + logger.WithFields(log.Fields{ + "raw_content_preview": fmt.Sprintf("%.200s...", string(body)), + }).Debug("Raw state file content preview") + + return stateContent, nil +} + +// getMapKeys returns a slice of keys from a map[string]interface{} +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/tools/tfe/get_tf_state_and_config_test.go b/pkg/tools/tfe/get_tf_state_and_config_test.go new file mode 100644 index 0000000..3f8a312 --- /dev/null +++ b/pkg/tools/tfe/get_tf_state_and_config_test.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestGetTfStateAndConfig(t *testing.T) { + logger := log.New() + logger.SetLevel(log.ErrorLevel) // Reduce noise in tests + + t.Run("tool creation", func(t *testing.T) { + tool := GetTfStateAndConfig(logger) + + assert.Equal(t, "get_tf_state_and_config", tool.Tool.Name) + assert.Contains(t, tool.Tool.Description, "Fetches both the current Terraform state and configuration") + assert.NotNil(t, tool.Handler) + + // Verify it's marked as read-only + assert.NotNil(t, tool.Tool.Annotations.ReadOnlyHint) + assert.True(t, *tool.Tool.Annotations.ReadOnlyHint) + + // Verify it's not marked as destructive + assert.NotNil(t, tool.Tool.Annotations.DestructiveHint) + assert.False(t, *tool.Tool.Annotations.DestructiveHint) + + // Check that required parameters are defined + assert.Contains(t, tool.Tool.InputSchema.Required, "terraform_org_name") + assert.Contains(t, tool.Tool.InputSchema.Required, "workspace_name") + }) + + t.Run("parameter validation", func(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectError bool + errorMsg string + }{ + { + name: "valid parameters", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + "workspace_name": "test-workspace", + }, + expectError: false, + }, + { + name: "missing org name", + params: map[string]interface{}{ + "workspace_name": "test-workspace", + }, + expectError: true, + errorMsg: "terraform_org_name", + }, + { + name: "missing workspace name", + params: map[string]interface{}{ + "terraform_org_name": "test-org", + }, + expectError: true, + errorMsg: "workspace_name", + }, + { + name: "empty org name", + params: map[string]interface{}{ + "terraform_org_name": "", + "workspace_name": "test-workspace", + }, + expectError: false, // Empty strings are valid - they get trimmed in the handler + }, + { + name: "whitespace org name", + params: map[string]interface{}{ + "terraform_org_name": " test-org ", + "workspace_name": "test-workspace", + }, + expectError: false, // Whitespace gets trimmed in the handler + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &MockCallToolRequest{params: tt.params} + + orgName, err1 := request.RequireString("terraform_org_name") + workspaceName, err2 := request.RequireString("workspace_name") + + if tt.expectError { + if strings.Contains(tt.errorMsg, "terraform_org_name") { + assert.Error(t, err1) + } + if strings.Contains(tt.errorMsg, "workspace_name") { + assert.Error(t, err2) + } + } else { + if _, ok := tt.params["terraform_org_name"]; ok { + assert.NoError(t, err1) + assert.Equal(t, tt.params["terraform_org_name"], orgName) + } + if _, ok := tt.params["workspace_name"]; ok { + assert.NoError(t, err2) + assert.Equal(t, tt.params["workspace_name"], workspaceName) + } + } + }) + } + }) +} diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index ae65b3b..afa5693 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -5,6 +5,7 @@ package tools import ( registryTools "github.com/hashicorp/terraform-mcp-server/pkg/tools/registry" + tfeTools "github.com/hashicorp/terraform-mcp-server/pkg/tools/tfe" "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" ) @@ -39,4 +40,8 @@ func RegisterTools(hcServer *server.MCPServer, logger *log.Logger) { getPolicyDetailsTool := registryTools.PolicyDetails(logger) hcServer.AddTool(getPolicyDetailsTool.Tool, getPolicyDetailsTool.Handler) + + // TFE tools + getTfStateAndConfigTool := tfeTools.GetTfStateAndConfig(logger) + hcServer.AddTool(getTfStateAndConfigTool.Tool, getTfStateAndConfigTool.Handler) }