diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1cc1a..8fab9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pkg/tools/dynamic_tool.go b/pkg/tools/dynamic_tool.go index 4ef4625..e796443 100644 --- a/pkg/tools/dynamic_tool.go +++ b/pkg/tools/dynamic_tool.go @@ -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) diff --git a/pkg/tools/tfe/workspace_tags.go b/pkg/tools/tfe/workspace_tags.go new file mode 100644 index 0000000..a674f43 --- /dev/null +++ b/pkg/tools/tfe/workspace_tags.go @@ -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 + }, + } +} diff --git a/pkg/tools/tfe/workspace_tags_test.go b/pkg/tools/tfe/workspace_tags_test.go new file mode 100644 index 0000000..a247e01 --- /dev/null +++ b/pkg/tools/tfe/workspace_tags_test.go @@ -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") + }) +}