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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion cmd/terraform-mcp-server/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions pkg/tools/dynamic_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions pkg/tools/tfe/force_unlock_workspace.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions pkg/tools/tfe/force_unlock_workspace_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
})
}
1 change: 1 addition & 0 deletions pkg/toolsets/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading