Skip to content
1 change: 1 addition & 0 deletions pkg/client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ type TerraformPolicyDetails struct {
} `json:"included"`
}

// TODO: Remove after migration is complete. No longer needed in new sdk tools.
type WorkspaceToolResponse struct {
Type string `jsonapi:"primary,tool"`
Success bool `jsonapi:"attr,success"`
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp-official/tools/tfe/organizations_list_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func ListTerraformOrganizationsTool() *mcp.Tool {
Properties: map[string]*jsonschema.Schema{
"organization_name": {Type: "string"},
"organization_email": {Type: "string"},
"created_at": {Type: "string"},
"created_at": {Type: "string", Format: "date-time"},
},
PropertyOrder: []string{"organization_name", "organization_email", "created_at"},
Required: []string{"organization_name", "organization_email", "created_at"},
Expand Down
23 changes: 23 additions & 0 deletions pkg/mcp-official/tools/tfe/static/default-cli-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
### CLI-driven runs

1. Add a code block to your Terraform configuration files to set up the cloud integration. You can add this configuration block to any .tf file in the directory where you run Terraform.

Example code

```hcl
terraform {
cloud {

organization = "<<your-terraform-org>>"

workspaces {
name = "<<your-terraform-workspace>>"
}
}
}
```

2. Run terraform init to initialize the workspace.
3. Run terraform apply to start the first run for this workspace.

For more details, see the [CLI workflow guide](https://developer.hashicorp.com/terraform/cloud-docs/run/cli).
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,8 @@ func TestUpdateWorkspaceVariableFunc_EmptyValueIsAChange(t *testing.T) {
VariableID: "var-1",
Value: &empty,
})
// gets past validation and fails on the client, which is the point.
// An empty string is a valid change — gets past validation and fails on the API call.
require.Error(t, err)
assert.Contains(t, err.Error(), "getting Terraform client")
}

func TestUpdateWorkspaceVariableToolOutputSchema(t *testing.T) {
Expand Down
195 changes: 195 additions & 0 deletions pkg/mcp-official/tools/tfe/workspaces_create_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright IBM Corp. 2025
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/google/jsonschema-go/jsonschema"
"github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform-mcp-server/pkg/mcp-official/client"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

const workspaceSourceName = "terraform-mcp-server"

// CreateWorkspaceArguments holds the inputs for creating a workspace.
type CreateWorkspaceArguments struct {
TerraformOrgName string `json:"terraform_org_name"`
WorkspaceName string `json:"workspace_name"`
Description string `json:"description,omitempty"`
TerraformVersion string `json:"terraform_version,omitempty"`
WorkingDirectory string `json:"working_directory,omitempty"`
AutoApply bool `json:"auto_apply,omitempty"`
ExecutionMode string `json:"execution_mode,omitempty"`
ProjectID string `json:"project_id,omitempty"`
VCSRepoIdentifier string `json:"vcs_repo_identifier,omitempty"`
VCSRepoBranch string `json:"vcs_repo_branch,omitempty"`
VCSRepoOAuthTokenID string `json:"vcs_repo_oauth_token_id,omitempty"`
Tags string `json:"tags,omitempty"`
}

// CreateWorkspaceTool describes the create_workspace tool.
func CreateWorkspaceTool() *mcp.Tool {
return &mcp.Tool{
Name: "create_workspace",
Description: "Creates a new Terraform workspace in the specified organization.",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"terraform_org_name": {
Type: "string",
Description: "The name of the Terraform Cloud/Enterprise organization",
},
"workspace_name": {
Type: "string",
Description: "The name of the workspace to create",
},
"description": {
Type: "string",
Description: "Optional description for the workspace",
},
"terraform_version": {
Type: "string",
Description: "Optional Terraform version to use (e.g., '1.5.0')",
},
"working_directory": {
Type: "string",
Description: "Optional working directory for Terraform operations",
},
"auto_apply": {
Type: "boolean",
Description: "Whether to automatically apply successful plans",
Default: json.RawMessage("false"),
},
"execution_mode": {
Type: "string",
Description: "Optional execution mode: local or remote. When omitted, the workspace inherits the applicable default.",
Enum: enumOf(validExecutionModes...),
},
"project_id": {
Type: "string",
Description: "Optional project ID to associate the workspace with",
},
"vcs_repo_identifier": {
Type: "string",
Description: "Optional VCS repository identifier (e.g., 'org/repo')",
},
"vcs_repo_branch": {
Type: "string",
Description: "Optional VCS repository branch (default: main/master)",
},
"vcs_repo_oauth_token_id": {
Type: "string",
Description: "OAuth token ID for VCS integration",
},
"tags": {
Type: "string",
Description: "Optional comma-separated list of tags to apply to the workspace",
},
},
PropertyOrder: []string{
"terraform_org_name", "workspace_name", "description", "terraform_version",
"working_directory", "auto_apply", "execution_mode", "project_id",
"vcs_repo_identifier", "vcs_repo_branch", "vcs_repo_oauth_token_id", "tags",
},
Required: []string{"terraform_org_name", "workspace_name"},
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
},
OutputSchema: workspaceDetailsSchema(),
Annotations: &mcp.ToolAnnotations{
Title: "Create a new Terraform workspace",
OpenWorldHint: jsonschema.Ptr(true),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
},
}
}

func CreateWorkspaceFunc(ctx context.Context, request *mcp.CallToolRequest, input CreateWorkspaceArguments) (*mcp.CallToolResult, *WorkspaceDetails, error) {
terraformOrgName := strings.TrimSpace(input.TerraformOrgName)
if terraformOrgName == "" {
return nil, nil, fmt.Errorf("terraform_org_name must not be blank")
}

workspaceName := strings.TrimSpace(input.WorkspaceName)
if workspaceName == "" {
return nil, nil, fmt.Errorf("workspace_name must not be blank")
}

options, err := workspaceCreateOptions(input, workspaceName)
if err != nil {
return nil, nil, err
}

tfeClient, err := client.GetTfeClient(ctx, client.SessionIDFromRequest(request))
if err != nil {
return nil, nil, fmt.Errorf("getting Terraform client: %w", err)
}

workspace, err := tfeClient.Workspaces.Create(ctx, terraformOrgName, options)
if err != nil {
return nil, nil, fmt.Errorf("creating workspace %q in organization %q: %w", workspaceName, terraformOrgName, err)
}

details := workspaceToDetails(workspace)
return nil, &details, nil
}

func workspaceCreateOptions(input CreateWorkspaceArguments, workspaceName string) (tfe.WorkspaceCreateOptions, error) {
options := tfe.WorkspaceCreateOptions{
Name: tfe.String(workspaceName),
AutoApply: tfe.Bool(input.AutoApply),
SourceName: tfe.String(workspaceSourceName),
}

if input.Description != "" {
options.Description = tfe.String(input.Description)
}
if input.TerraformVersion != "" {
options.TerraformVersion = tfe.String(input.TerraformVersion)
}
if input.WorkingDirectory != "" {
options.WorkingDirectory = tfe.String(input.WorkingDirectory)
}
if input.ProjectID != "" {
options.Project = &tfe.Project{ID: input.ProjectID}
}

if input.ExecutionMode != "" {
switch input.ExecutionMode {
case executionModeLocal, executionModeRemote:
options.ExecutionMode = tfe.String(input.ExecutionMode)
default:
return tfe.WorkspaceCreateOptions{}, fmt.Errorf("execution_mode %q must be one of: %s", input.ExecutionMode, strings.Join(validExecutionModes, ", "))
}
}

if input.VCSRepoIdentifier != "" {
if input.VCSRepoOAuthTokenID == "" {
return tfe.WorkspaceCreateOptions{}, fmt.Errorf("vcs_repo_oauth_token_id is required when vcs_repo_identifier is provided")
}

options.VCSRepo = &tfe.VCSRepoOptions{
Identifier: tfe.String(input.VCSRepoIdentifier),
OAuthTokenID: tfe.String(input.VCSRepoOAuthTokenID),
}
if input.VCSRepoBranch != "" {
options.VCSRepo.Branch = tfe.String(input.VCSRepoBranch)
}
}

if input.Tags != "" {
for _, name := range strings.Split(strings.TrimSpace(input.Tags), ",") {
if name := strings.TrimSpace(name); name != "" {
options.Tags = append(options.Tags, &tfe.Tag{Name: name})
}
}
}

return options, nil
}
83 changes: 83 additions & 0 deletions pkg/mcp-official/tools/tfe/workspaces_create_tool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright IBM Corp. 2025
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateWorkspaceTool(t *testing.T) {
tool := CreateWorkspaceTool()
assert.Equal(t, "create_workspace", tool.Name)
assert.Contains(t, tool.Description, "Creates a new Terraform workspace")
require.NotNil(t, tool.Annotations)
assert.Equal(t, "Create a new Terraform workspace", tool.Annotations.Title)
assert.False(t, tool.Annotations.ReadOnlyHint)
require.NotNil(t, tool.Annotations.OpenWorldHint)
assert.True(t, *tool.Annotations.OpenWorldHint)
require.NotNil(t, tool.Annotations.DestructiveHint)
assert.False(t, *tool.Annotations.DestructiveHint)
}

func TestCreateWorkspaceToolInputSchema(t *testing.T) {
schema := inputSchema(t, CreateWorkspaceTool().InputSchema)
assert.Equal(t, []string{
"terraform_org_name", "workspace_name", "description", "terraform_version",
"working_directory", "auto_apply", "execution_mode", "project_id",
"vcs_repo_identifier", "vcs_repo_branch", "vcs_repo_oauth_token_id", "tags",
}, schema.PropertyOrder)
assert.Equal(t, []string{"terraform_org_name", "workspace_name"}, schema.Required)
assert.Equal(t, "boolean", schema.Properties["auto_apply"].Type)
assert.JSONEq(t, "false", string(schema.Properties["auto_apply"].Default))
assert.Equal(t, enumOf(validExecutionModes...), schema.Properties["execution_mode"].Enum)
assert.Equal(t, "string", schema.Properties["vcs_repo_oauth_token_id"].Type)
require.NotNil(t, schema.AdditionalProperties)
assert.NotNil(t, schema.AdditionalProperties.Not)
}

func TestCreateWorkspaceFuncRejectsBlankArguments(t *testing.T) {
tests := []struct {
input CreateWorkspaceArguments
wantErr string
}{
{input: CreateWorkspaceArguments{WorkspaceName: "workspace"}, wantErr: "terraform_org_name must not be blank"},
{input: CreateWorkspaceArguments{TerraformOrgName: "organization", WorkspaceName: " "}, wantErr: "workspace_name must not be blank"},
{input: CreateWorkspaceArguments{TerraformOrgName: "organization", WorkspaceName: "workspace", ExecutionMode: "invalid"}, wantErr: `execution_mode "invalid" must be one of: local, remote`},
{input: CreateWorkspaceArguments{TerraformOrgName: "organization", WorkspaceName: "workspace", ExecutionMode: "agent"}, wantErr: `execution_mode "agent" must be one of: local, remote`},
{input: CreateWorkspaceArguments{TerraformOrgName: "organization", WorkspaceName: "workspace", VCSRepoIdentifier: "org/repo"}, wantErr: "vcs_repo_oauth_token_id is required when vcs_repo_identifier is provided"},
}

for _, test := range tests {
_, _, err := CreateWorkspaceFunc(t.Context(), nil, test.input)
require.EqualError(t, err, test.wantErr)
}
}

func TestWorkspaceCreateOptions(t *testing.T) {
options, err := workspaceCreateOptions(CreateWorkspaceArguments{
AutoApply: true,
ExecutionMode: executionModeLocal,
Tags: " production, ,team-platform ",
VCSRepoIdentifier: "org/repo",
VCSRepoBranch: "feature",
VCSRepoOAuthTokenID: "ot-123",
}, "workspace")
require.NoError(t, err)

require.NotNil(t, options.Name)
assert.Equal(t, "workspace", *options.Name)
require.NotNil(t, options.AutoApply)
assert.True(t, *options.AutoApply)
require.NotNil(t, options.ExecutionMode)
assert.Equal(t, "local", *options.ExecutionMode)
require.Len(t, options.Tags, 2)
assert.Equal(t, "production", options.Tags[0].Name)
assert.Equal(t, "team-platform", options.Tags[1].Name)
require.NotNil(t, options.VCSRepo)
assert.Equal(t, "org/repo", *options.VCSRepo.Identifier)
assert.Equal(t, workspaceSourceName, *options.SourceName)
}
67 changes: 67 additions & 0 deletions pkg/mcp-official/tools/tfe/workspaces_delete_tool_safely.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright IBM Corp. 2025
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"context"
"fmt"
"strings"

"github.com/google/jsonschema-go/jsonschema"
"github.com/hashicorp/terraform-mcp-server/pkg/mcp-official/client"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// DeleteWorkspaceSafelyArguments holds the workspace ID to delete.
type DeleteWorkspaceSafelyArguments struct {
WorkspaceID string `json:"workspace_id" jsonschema:"The ID of the workspace to delete (e.g., 'ws-abc123def456')"`
}

// DeleteWorkspaceSafelyResponse reports the workspace deleted by the tool.
type DeleteWorkspaceSafelyResponse struct {
WorkspaceID string `json:"workspace_id"`
WorkspaceName string `json:"workspace_name"`
Deleted bool `json:"deleted"`
}

// DeleteWorkspaceSafelyTool describes the delete_workspace_safely tool.
func DeleteWorkspaceSafelyTool() *mcp.Tool {
return &mcp.Tool{
Name: "delete_workspace_safely",
Description: "Safely deletes a Terraform workspace by ID only if it is not managing any resources. This prevents accidental deletion of workspaces that still have active infrastructure. This is a destructive operation.",
Annotations: &mcp.ToolAnnotations{
Title: "Safely delete a Terraform workspace by ID",
OpenWorldHint: jsonschema.Ptr(true),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
},
}
}

func DeleteWorkspaceSafelyFunc(ctx context.Context, request *mcp.CallToolRequest, input DeleteWorkspaceSafelyArguments) (*mcp.CallToolResult, *DeleteWorkspaceSafelyResponse, error) {
workspaceID := strings.TrimSpace(input.WorkspaceID)
if workspaceID == "" {
return nil, nil, fmt.Errorf("workspace_id must not be blank")
}

tfeClient, err := client.GetTfeClient(ctx, client.SessionIDFromRequest(request))
if err != nil {
return nil, nil, fmt.Errorf("getting Terraform client: %w", err)
}

workspace, err := tfeClient.Workspaces.ReadByID(ctx, workspaceID)
if err != nil {
return nil, nil, fmt.Errorf("reading workspace %q: %w", workspaceID, err)
}

if err := tfeClient.Workspaces.SafeDeleteByID(ctx, workspaceID); err != nil {
return nil, nil, fmt.Errorf("safely deleting workspace %q: %w", workspaceID, err)
}

return nil, &DeleteWorkspaceSafelyResponse{
WorkspaceID: workspaceID,
WorkspaceName: workspace.Name,
Deleted: true,
}, nil
}
Loading
Loading