Skip to content
Merged
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 @@ -7,6 +7,7 @@ FEATURES
* Implement dynamic tool registration. See [#121](https://github.com/hashicorp/terraform-mcp-server/pull/121)
* Adding 2 new HCP TF/TFE tools for admins. List Terraform organizations & projects. See [#121](https://github.com/hashicorp/terraform-mcp-server/pull/121)
* Adding 4 new HCP TF/TFE tools for private registry support. See [#142](https://github.com/hashicorp/terraform-mcp-server/pull/142)
* Adding 2 new HCP TF/TFE tools for workspace tags. See [#171](https://github.com/hashicorp/terraform-mcp-server/pull/171)
* Adding 4 new HCP TF/TFE tools for creating Terraform runs. See [#159](https://github.com/hashicorp/terraform-mcp-server/pull/159)

IMPROVEMENTS
Expand Down
7 changes: 7 additions & 0 deletions pkg/tools/dynamic_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ func (r *DynamicToolRegistry) registerTFETools() {
getPrivateModuleDetailsTool := r.createDynamicTFETool("get_private_module_details", tfeTools.GetPrivateModuleDetails)
r.mcpServer.AddTool(getPrivateModuleDetailsTool.Tool, getPrivateModuleDetailsTool.Handler)

// Workspace tags tools
createWorkspaceTagsTool := r.createDynamicTFETool("create_workspace_tags", tfeTools.CreateWorkspaceTags)
r.mcpServer.AddTool(createWorkspaceTagsTool.Tool, createWorkspaceTagsTool.Handler)

readWorkspaceTagsTool := r.createDynamicTFETool("read_workspace_tags", tfeTools.ReadWorkspaceTags)
r.mcpServer.AddTool(readWorkspaceTagsTool.Tool, readWorkspaceTagsTool.Handler)

// Terraform run tools
listRunsTool := r.createDynamicTFETool("list_runs", tfeTools.ListRuns)
r.mcpServer.AddTool(listRunsTool.Tool, listRunsTool.Handler)
Expand Down
168 changes: 168 additions & 0 deletions pkg/tools/tfe/workspace_tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"context"
"fmt"
"strings"

"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"
)

// CreateWorkspaceTags creates a tool to add tags to a workspace.
func CreateWorkspaceTags(logger *log.Logger) server.ServerTool {
return server.ServerTool{
Tool: mcp.NewTool("create_workspace_tags",
mcp.WithDescription("Add tags to a Terraform workspace."),
mcp.WithString("terraform_org_name",
mcp.Required(),
mcp.Description("Organization name"),
),
mcp.WithString("workspace_name",
mcp.Required(),
mcp.Description("Workspace name"),
),
mcp.WithString("tags",
mcp.Required(),
mcp.Description("Comma-separated list of tag names to add, for key-value tags use key:value"),
),
),
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
orgName, err := request.RequireString("terraform_org_name")
if err != nil {
return nil, utils.LogAndReturnError(logger, "terraform_org_name is required", err)
}
workspaceName, err := request.RequireString("workspace_name")
if err != nil {
return nil, utils.LogAndReturnError(logger, "workspace_name is required", err)
}
tagsStr, err := request.RequireString("tags")
if err != nil {
return nil, utils.LogAndReturnError(logger, "tags is required", err)
}

tagNames := strings.Split(strings.TrimSpace(tagsStr), ",")
var tags []*tfe.TagBinding
for _, tagName := range tagNames {
tagName = strings.TrimSpace(tagName)
// Support key:value format for key-value tags
if strings.Contains(tagName, ":") {
parts := strings.SplitN(tagName, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key != "" {
tags = append(tags, &tfe.TagBinding{Key: key, Value: value})
}
continue
}
// Otherwise treat as a tag with only a key
if tagName != "" {
tags = append(tags, &tfe.TagBinding{Key: tagName})
}
}

tfeClient, err := client.GetTfeClientFromContext(ctx, logger)
if err != nil {
return nil, utils.LogAndReturnError(logger, "getting Terraform client", err)
}

workspace, err := tfeClient.Workspaces.Read(ctx, orgName, workspaceName)
if err != nil {
return nil, utils.LogAndReturnError(logger, "reading workspace", err)
}

_, err = tfeClient.Workspaces.AddTagBindings(ctx, workspace.ID, tfe.WorkspaceAddTagBindingsOptions{
TagBindings: tags,
})
if err != nil {
return nil, utils.LogAndReturnError(logger, "adding tags to workspace", err)
}

return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(fmt.Sprintf("Added %d tags to workspace %s", len(tags), workspaceName)),
},
}, nil
},
}
}

// ReadWorkspaceTags creates a tool to read tags from a workspace.
func ReadWorkspaceTags(logger *log.Logger) server.ServerTool {
return server.ServerTool{
Tool: mcp.NewTool("read_workspace_tags",
mcp.WithDescription("Read all tags from a Terraform workspace."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithString("terraform_org_name",
mcp.Required(),
mcp.Description("Organization name"),
),
mcp.WithString("workspace_name",
mcp.Required(),
mcp.Description("Workspace name"),
),
),
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
orgName, err := request.RequireString("terraform_org_name")
if err != nil {
return nil, utils.LogAndReturnError(logger, "terraform_org_name is required", err)
}
workspaceName, err := request.RequireString("workspace_name")
if err != nil {
return nil, utils.LogAndReturnError(logger, "workspace_name is required", err)
}

tfeClient, err := client.GetTfeClientFromContext(ctx, logger)
if err != nil {
return nil, utils.LogAndReturnError(logger, "getting Terraform client", err)
}

workspace, err := tfeClient.Workspaces.Read(ctx, orgName, workspaceName)
if err != nil {
return nil, utils.LogAndReturnError(logger, "reading workspace", err)
}

var tagNames []string
tags, err := tfeClient.Workspaces.ListTags(ctx, workspace.ID, nil)
if err != nil {
return nil, utils.LogAndReturnError(logger, "listing tags", err)
}
for _, tag := range tags.Items {
tagNames = append(tagNames, tag.Name)
}

var tagBindings []string
bindings, err := tfeClient.Workspaces.ListTagBindings(ctx, workspace.ID)
if err != nil {
return nil, utils.LogAndReturnError(logger, "listing tag bindings", err)
}
for _, binding := range bindings {
if binding.Value != "" {
tagBindings = append(tagBindings, fmt.Sprintf("%s:%s", binding.Key, binding.Value))
} else {
tagBindings = append(tagBindings, binding.Key)
}
}

tagResponse := fmt.Sprintf("Workspace %s has %d tags: %s", workspaceName, len(tagNames), strings.Join(tagNames, ", "))
if len(tagBindings) > 0 {
tagResponse += fmt.Sprintf("Workspace %s has %d tag bindings: %s", workspaceName, len(tagBindings), strings.Join(tagBindings, ", "))
}

return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(tagResponse),
},
}, nil
},
}
}
44 changes: 44 additions & 0 deletions pkg/tools/tfe/workspace_tags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"testing"

log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)

func TestCreateWorkspaceTags(t *testing.T) {
logger := log.New()
logger.SetLevel(log.ErrorLevel)

t.Run("tool creation", func(t *testing.T) {
tool := CreateWorkspaceTags(logger)

assert.Equal(t, "create_workspace_tags", tool.Tool.Name)
assert.Contains(t, tool.Tool.Description, "Add tags to a Terraform workspace")
assert.NotNil(t, tool.Handler)

assert.Contains(t, tool.Tool.InputSchema.Required, "terraform_org_name")
assert.Contains(t, tool.Tool.InputSchema.Required, "workspace_name")
assert.Contains(t, tool.Tool.InputSchema.Required, "tags")
})
}

func TestReadWorkspaceTags(t *testing.T) {
logger := log.New()
logger.SetLevel(log.ErrorLevel)

t.Run("tool creation", func(t *testing.T) {
tool := ReadWorkspaceTags(logger)

assert.Equal(t, "read_workspace_tags", tool.Tool.Name)
assert.Contains(t, tool.Tool.Description, "Read all tags from a Terraform workspace")
assert.NotNil(t, tool.Handler)

assert.Contains(t, tool.Tool.InputSchema.Required, "terraform_org_name")
assert.Contains(t, tool.Tool.InputSchema.Required, "workspace_name")
})
}
Loading