diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c61adb8..22387f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ FEATURES +* [New Tool] `force_unlock_workspace` Force-unlocks a Terraform workspace by ID via the go-tfe `Workspaces.ForceUnlock` API; destructive, gated behind `ENABLE_TF_OPERATIONS` [371](https://github.com/hashicorp/terraform-mcp-server/pull/371) * [New Tool] `get_sentinel_mock` Export and download Sentinel mock bundle data for a Terraform plan IMPROVEMENTS diff --git a/cmd/terraform-mcp-server/instructions.md b/cmd/terraform-mcp-server/instructions.md index af7fc361..395c15f2 100644 --- a/cmd/terraform-mcp-server/instructions.md +++ b/cmd/terraform-mcp-server/instructions.md @@ -34,8 +34,9 @@ The Terraform MCP server provides tools for generating better Terraform code thr ### Workspace Management - **Discovery**: `search_workspaces` (empty query returns all) → `get_workspace_details` -- **Operations**: `create_workspace`, `update_workspace`, `delete_workspace_safely` +- **Operations**: `create_workspace`, `update_workspace`, `delete_workspace_safely`, `force_unlock_workspace` - `delete_workspace_safely` only works if workspace has no managed resources +- `force_unlock_workspace` recovers a workspace whose lock is stuck; destructive — use only after the responsible run has been completed or cancelled ### Run Execution - **Discovery**: `search_run` (empty query returns all) → `get_run_details` (supports json output) diff --git a/pkg/tools/dynamic_tool.go b/pkg/tools/dynamic_tool.go index d17b4ca7..91e80c47 100644 --- a/pkg/tools/dynamic_tool.go +++ b/pkg/tools/dynamic_tool.go @@ -143,6 +143,12 @@ func (r *DynamicToolRegistry) registerTFETools() { r.mcpServer.AddTool(tool.Tool, tool.Handler) } + // Only register force_unlock_workspace if TF operations are enabled AND toolset is enabled + if isTerraformOperationsEnabled() && toolsets.IsToolEnabled("force_unlock_workspace", r.enabledToolsets) { + tool := r.createDynamicTFETool("force_unlock_workspace", tfeTools.ForceUnlockWorkspace) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } + // Registry-private toolset - Private provider tools if toolsets.IsToolEnabled("search_private_providers", r.enabledToolsets) { tool := r.createDynamicTFETool("search_private_providers", tfeTools.SearchPrivateProviders) diff --git a/pkg/tools/tfe/force_unlock_workspace.go b/pkg/tools/tfe/force_unlock_workspace.go new file mode 100644 index 00000000..f6cfac73 --- /dev/null +++ b/pkg/tools/tfe/force_unlock_workspace.go @@ -0,0 +1,76 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "context" + "encoding/json" + "strings" + + "github.com/hashicorp/terraform-mcp-server/pkg/client" + log "github.com/sirupsen/logrus" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ForceUnlockWorkspace creates a tool to force-unlock a Terraform workspace by ID. +// +// Force-unlock is intended as a recovery action when a workspace lock is stuck +// (for example, after a run was interrupted in a way that left the lock held). +// It should be used with caution: forcing a lock release while a run is still +// in progress can leave the workspace state in an inconsistent condition. +func ForceUnlockWorkspace(logger *log.Logger) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("force_unlock_workspace", + mcp.WithDescription(`Force-unlocks a Terraform workspace by ID. Use this to recover a workspace whose lock is stuck (for example after an interrupted run). This is a destructive operation: forcing a lock release while a run is still active can leave the workspace state in an inconsistent condition. Prefer running the responsible run to completion, cancelling it, or using the workspace owner's regular unlock before resorting to force-unlock.`), + mcp.WithTitleAnnotation("Force-unlock a Terraform workspace by ID"), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("workspace_id", + mcp.Required(), + mcp.Description("The ID of the workspace to force-unlock (e.g., 'ws-abc123def456')"), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return forceUnlockWorkspaceHandler(ctx, request, logger) + }, + } +} + +func forceUnlockWorkspaceHandler(ctx context.Context, request mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { + workspaceID, err := request.RequireString("workspace_id") + if err != nil { + return ToolError(logger, "missing required input: workspace_id", err) + } + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return ToolErrorf(logger, "workspace_id must not be empty") + } + + tfeClient, err := client.GetTfeClientFromContext(ctx, logger) + if err != nil { + return ToolError(logger, "failed to get Terraform client - ensure TFE_TOKEN and TFE_ADDRESS are configured", err) + } + + workspace, err := tfeClient.Workspaces.ForceUnlock(ctx, workspaceID) + if err != nil { + return ToolErrorf(logger, "failed to force-unlock workspace '%s': %v", workspaceID, err) + } + + result := map[string]interface{}{ + "success": true, + "message": "Workspace force-unlocked successfully.", + "workspace_id": workspace.ID, + "workspace_name": workspace.Name, + "locked": workspace.Locked, + } + + resultJSON, err := json.Marshal(result) + if err != nil { + return ToolError(logger, "failed to marshal result", err) + } + return mcp.NewToolResultText(string(resultJSON)), nil +} diff --git a/pkg/tools/tfe/force_unlock_workspace_test.go b/pkg/tools/tfe/force_unlock_workspace_test.go new file mode 100644 index 00000000..cecba53d --- /dev/null +++ b/pkg/tools/tfe/force_unlock_workspace_test.go @@ -0,0 +1,97 @@ +// 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 TestForceUnlockWorkspace(t *testing.T) { + logger := log.New() + logger.SetLevel(log.ErrorLevel) // Reduce noise in tests + + t.Run("tool creation", func(t *testing.T) { + tool := ForceUnlockWorkspace(logger) + + assert.Equal(t, "force_unlock_workspace", tool.Tool.Name) + assert.Contains(t, tool.Tool.Description, "Force-unlocks a Terraform workspace") + assert.NotNil(t, tool.Handler) + + // Verify it's marked as destructive and not read-only + assert.NotNil(t, tool.Tool.Annotations.DestructiveHint) + assert.True(t, *tool.Tool.Annotations.DestructiveHint) + assert.NotNil(t, tool.Tool.Annotations.ReadOnlyHint) + assert.False(t, *tool.Tool.Annotations.ReadOnlyHint) + assert.NotNil(t, tool.Tool.Annotations.OpenWorldHint) + assert.True(t, *tool.Tool.Annotations.OpenWorldHint) + + // Required parameter + assert.Contains(t, tool.Tool.InputSchema.Required, "workspace_id") + }) + + t.Run("parameter validation", func(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectError bool + }{ + { + name: "valid workspace ID", + params: map[string]interface{}{ + "workspace_id": "ws-123456", + }, + expectError: false, + }, + { + name: "missing workspace ID", + params: map[string]interface{}{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &MockCallToolRequest{params: tt.params} + + workspaceID, err := request.RequireString("workspace_id") + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if val, ok := tt.params["workspace_id"]; ok { + assert.Equal(t, val, workspaceID) + } + } + }) + } + }) + + t.Run("workspace ID format validation", func(t *testing.T) { + tests := []struct { + name string + workspaceID string + expectValid bool + }{ + {"valid workspace ID", "ws-123456789abcdef", true}, + {"valid short workspace ID", "ws-123abc", true}, + {"invalid format - no prefix", "123456789abcdef", false}, + {"invalid format - wrong prefix", "workspace-123456", false}, + {"empty workspace ID", "", false}, + {"only prefix", "ws-", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simple validation: workspace ID should start with "ws-" and have content after. + isValid := strings.HasPrefix(tt.workspaceID, "ws-") && len(tt.workspaceID) > 3 + assert.Equal(t, tt.expectValid, isValid) + }) + } + }) +} diff --git a/pkg/toolsets/mapping.go b/pkg/toolsets/mapping.go index 1c2417aa..55e700a7 100644 --- a/pkg/toolsets/mapping.go +++ b/pkg/toolsets/mapping.go @@ -32,6 +32,7 @@ var ToolToToolset = map[string]string{ "create_no_code_workspace": Terraform, "update_workspace": Terraform, "delete_workspace_safely": Terraform, + "force_unlock_workspace": Terraform, "list_runs": Terraform, "get_run_details": Terraform, "get_plan_details": Terraform,