diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b9872e..aa2fac1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +FEATURES + +* **Toolsets Flag**: Added `--toolsets` flag to selectively enable tool groups. Three toolset groups are available: `registry` (public Terraform Registry), `registry-private` (private TFE/TFC registry), and `terraform` (TFE/TFC operations). Default is `registry` only. + ## 0.3.3 (Nov 21, 2025) IMPROVEMENTS diff --git a/cmd/terraform-mcp-server/init.go b/cmd/terraform-mcp-server/init.go index 420fc686..e572e159 100644 --- a/cmd/terraform-mcp-server/init.go +++ b/cmd/terraform-mcp-server/init.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-mcp-server/pkg/client" "github.com/hashicorp/terraform-mcp-server/pkg/resources" "github.com/hashicorp/terraform-mcp-server/pkg/tools" + "github.com/hashicorp/terraform-mcp-server/pkg/toolsets" "github.com/hashicorp/terraform-mcp-server/version" "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" @@ -37,7 +38,7 @@ var ( Use: "stdio", Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, - Run: func(_ *cobra.Command, _ []string) { + Run: func(cmd *cobra.Command, _ []string) { logFile, err := rootCmd.PersistentFlags().GetString("log-file") if err != nil { stdlog.Fatal("Failed to get log file:", err) @@ -47,7 +48,9 @@ var ( stdlog.Fatal("Failed to initialize logger:", err) } - if err := runStdioServer(logger); err != nil { + enabledToolsets := getToolsetsFromCmd(cmd.Root(), logger) + + if err := runStdioServer(logger, enabledToolsets); err != nil { stdlog.Fatal("failed to run stdio server:", err) } }, @@ -81,7 +84,9 @@ var ( stdlog.Fatal("Failed to get endpoint path:", err) } - if err := runHTTPServer(logger, host, port, endpointPath); err != nil { + enabledToolsets := getToolsetsFromCmd(cmd.Root(), logger) + + if err := runHTTPServer(logger, host, port, endpointPath, enabledToolsets); err != nil { stdlog.Fatal("failed to run streamableHTTP server:", err) } }, @@ -104,6 +109,7 @@ func init() { cobra.OnInitialize(initConfig) rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") + rootCmd.PersistentFlags().String("toolsets", "default", toolsets.GenerateToolsetsHelp()) // Add StreamableHTTP command flags (avoid 'h' shorthand conflict with help) streamableHTTPCmd.Flags().String("transport-host", "127.0.0.1", "Host to bind to") @@ -142,8 +148,8 @@ func initLogger(outPath string) (*log.Logger, error) { } // registerToolsAndResources registers tools and resources with the MCP server -func registerToolsAndResources(hcServer *server.MCPServer, logger *log.Logger) { - tools.RegisterTools(hcServer, logger) +func registerToolsAndResources(hcServer *server.MCPServer, logger *log.Logger, enabledToolsets []string) { + tools.RegisterTools(hcServer, logger, enabledToolsets) resources.RegisterResources(hcServer, logger) resources.RegisterResourceTemplates(hcServer, logger) } diff --git a/cmd/terraform-mcp-server/main.go b/cmd/terraform-mcp-server/main.go index e913d7d5..ec887b23 100644 --- a/cmd/terraform-mcp-server/main.go +++ b/cmd/terraform-mcp-server/main.go @@ -14,6 +14,7 @@ import ( "syscall" "github.com/hashicorp/terraform-mcp-server/pkg/client" + "github.com/hashicorp/terraform-mcp-server/pkg/toolsets" "github.com/hashicorp/terraform-mcp-server/version" "github.com/mark3labs/mcp-go/server" @@ -24,27 +25,27 @@ import ( //go:embed instructions.md var instructions string -func runHTTPServer(logger *log.Logger, host string, port string, endpointPath string) error { +func runHTTPServer(logger *log.Logger, host string, port string, endpointPath string, enabledToolsets []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - hcServer := NewServer(version.Version, logger) - registerToolsAndResources(hcServer, logger) + hcServer := NewServer(version.Version, logger, enabledToolsets) + registerToolsAndResources(hcServer, logger, enabledToolsets) return streamableHTTPServerInit(ctx, hcServer, logger, host, port, endpointPath) } -func runStdioServer(logger *log.Logger) error { +func runStdioServer(logger *log.Logger, enabledToolsets []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - hcServer := NewServer(version.Version, logger) - registerToolsAndResources(hcServer, logger) + hcServer := NewServer(version.Version, logger, enabledToolsets) + registerToolsAndResources(hcServer, logger, enabledToolsets) return serverInit(ctx, hcServer, logger) } -func NewServer(version string, logger *log.Logger, opts ...server.ServerOption) *server.MCPServer { +func NewServer(version string, logger *log.Logger, enabledToolsets []string, opts ...server.ServerOption) *server.MCPServer { // Create rate limiting middleware with environment-based configuration rateLimitConfig := client.LoadRateLimitConfigFromEnv() rateLimitMiddleware := client.NewRateLimitMiddleware(rateLimitConfig, logger) @@ -80,6 +81,33 @@ func NewServer(version string, logger *log.Logger, opts ...server.ServerOption) return s } +// parseToolsets parses and validates the toolsets flag value +func parseToolsets(toolsetsFlag string, logger *log.Logger) []string { + rawToolsets := strings.Split(toolsetsFlag, ",") + + cleaned, invalid := toolsets.CleanToolsets(rawToolsets) + if len(invalid) > 0 { + logger.Warnf("Invalid toolsets ignored: %v", invalid) + } + + expanded := toolsets.ExpandDefaultToolset(cleaned) + + logger.Infof("Enabled toolsets: %v", expanded) + return expanded +} + +func getToolsetsFromCmd(cmd *cobra.Command, logger *log.Logger) []string { + toolsetsFlag, err := cmd.Flags().GetString("toolsets") + if err != nil { + toolsetsFlag, err = cmd.Root().PersistentFlags().GetString("toolsets") + if err != nil { + logger.Warnf("Failed to get toolsets flag, using default: %v", err) + toolsetsFlag = "default" + } + } + return parseToolsets(toolsetsFlag, logger) +} + // runDefaultCommand handles the default behavior when no subcommand is provided func runDefaultCommand(cmd *cobra.Command, _ []string) { // Default to stdio mode when no subcommand is provided @@ -92,7 +120,10 @@ func runDefaultCommand(cmd *cobra.Command, _ []string) { stdlog.Fatal("Failed to initialize logger:", err) } - if err := runStdioServer(logger); err != nil { + // Get toolsets from the command that was passed in + enabledToolsets := getToolsetsFromCmd(cmd, logger) + + if err := runStdioServer(logger, enabledToolsets); err != nil { stdlog.Fatal("failed to run stdio server:", err) } } @@ -110,7 +141,9 @@ func main() { stdlog.Fatal("Failed to initialize logger:", err) } - if err := runHTTPServer(logger, host, port, endpointPath); err != nil { + enabledToolsets := getToolsetsFromCmd(rootCmd, logger) + + if err := runHTTPServer(logger, host, port, endpointPath, enabledToolsets); err != nil { stdlog.Fatal("failed to run StreamableHTTP server:", err) } return diff --git a/go.mod b/go.mod index 27156c44..2c996109 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.25.4 require ( github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-retryablehttp v0.7.8 - github.com/hashicorp/go-tfe v1.96.0 + github.com/hashicorp/go-tfe v1.97.0 github.com/hashicorp/jsonapi v1.5.0 - github.com/mark3labs/mcp-go v0.43.0 + github.com/mark3labs/mcp-go v0.43.2 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/time v0.14.0 @@ -24,7 +24,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-slug v0.16.8 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect diff --git a/go.sum b/go.sum index 76700f24..ae58f290 100644 --- a/go.sum +++ b/go.sum @@ -30,12 +30,12 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-slug v0.16.8 h1:f4/sDZqRsxx006HrE6e9BE5xO9lWXydKhVoH6Kb0v1M= github.com/hashicorp/go-slug v0.16.8/go.mod h1:hB4mUcVHl4RPu0205s0fwmB9i31MxQgeafGkko3FD+Y= -github.com/hashicorp/go-tfe v1.96.0 h1:goTDOZIQ8rsf1vRQXvqvK8v/inD4SQe5T2vcEX1q2MU= -github.com/hashicorp/go-tfe v1.96.0/go.mod h1:umRhpwmiMAa5Dhu8dzF0itJfBZHJPoTmS8BpNZs9+2Y= +github.com/hashicorp/go-tfe v1.97.0 h1:UmIUUPuWAVPKxTa9N5qTUWd7FblKbgLq+HBio1X7/Qc= +github.com/hashicorp/go-tfe v1.97.0/go.mod h1:nmGZMS3pdU7gPPmoe1xYhzU9O2BmasV36XggDOSCDW0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/jsonapi v1.5.0 h1:toO1EpzVl1b3xTjC/Tw4XMIlHgJreeTnyb1a1sHnlPk= github.com/hashicorp/jsonapi v1.5.0/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -51,8 +51,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA= -github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -73,8 +73,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/pkg/tools/dynamic_tool.go b/pkg/tools/dynamic_tool.go index 59f6c77a..795339b8 100644 --- a/pkg/tools/dynamic_tool.go +++ b/pkg/tools/dynamic_tool.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-mcp-server/pkg/client" tfeTools "github.com/hashicorp/terraform-mcp-server/pkg/tools/tfe" + "github.com/hashicorp/terraform-mcp-server/pkg/toolsets" "github.com/hashicorp/terraform-mcp-server/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -23,17 +24,19 @@ type DynamicToolRegistry struct { tfeToolsRegistered bool mcpServer *server.MCPServer logger *log.Logger + enabledToolsets []string } var globalToolRegistry *DynamicToolRegistry // registerDynamicTools registers the global tool registry -func registerDynamicTools(mcpServer *server.MCPServer, logger *log.Logger) { +func registerDynamicTools(mcpServer *server.MCPServer, logger *log.Logger, enabledToolsets []string) { globalToolRegistry = &DynamicToolRegistry{ sessionsWithTFE: make(map[string]bool), tfeToolsRegistered: false, mcpServer: mcpServer, logger: logger, + enabledToolsets: enabledToolsets, } // Set the callback in the client package to avoid circular imports @@ -102,107 +105,157 @@ func (r *DynamicToolRegistry) registerTFETools() { r.logger.Info("Registering TFE tools - first session with valid TFE client detected") - // Create TFE tools with dynamic availability checking - listTerraformOrgsTool := r.createDynamicTFETool("list_terraform_orgs", tfeTools.ListTerraformOrgs) - r.mcpServer.AddTool(listTerraformOrgsTool.Tool, listTerraformOrgsTool.Handler) + // Terraform toolset - Organization and Project tools + if toolsets.IsToolEnabled("list_terraform_orgs", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_terraform_orgs", tfeTools.ListTerraformOrgs) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - listTerraformProjectsTool := r.createDynamicTFETool("list_terraform_projects", tfeTools.ListTerraformProjects) - r.mcpServer.AddTool(listTerraformProjectsTool.Tool, listTerraformProjectsTool.Handler) + if toolsets.IsToolEnabled("list_terraform_projects", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_terraform_projects", tfeTools.ListTerraformProjects) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Workspace management tools - ListWorkspacesTool := r.createDynamicTFETool("list_workspaces", tfeTools.ListWorkspaces) - r.mcpServer.AddTool(ListWorkspacesTool.Tool, ListWorkspacesTool.Handler) + // Terraform toolset - Workspace management tools + if toolsets.IsToolEnabled("list_workspaces", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_workspaces", tfeTools.ListWorkspaces) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - getWorkspaceDetailsTool := r.createDynamicTFETool("get_workspace_details", tfeTools.GetWorkspaceDetails) - r.mcpServer.AddTool(getWorkspaceDetailsTool.Tool, getWorkspaceDetailsTool.Handler) + if toolsets.IsToolEnabled("get_workspace_details", r.enabledToolsets) { + tool := r.createDynamicTFETool("get_workspace_details", tfeTools.GetWorkspaceDetails) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - createWorkspaceTool := r.createDynamicTFETool("create_workspace", tfeTools.CreateWorkspace) - r.mcpServer.AddTool(createWorkspaceTool.Tool, createWorkspaceTool.Handler) + if toolsets.IsToolEnabled("create_workspace", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_workspace", tfeTools.CreateWorkspace) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - updateWorkspaceTool := r.createDynamicTFETool("update_workspace", tfeTools.UpdateWorkspace) - r.mcpServer.AddTool(updateWorkspaceTool.Tool, updateWorkspaceTool.Handler) + if toolsets.IsToolEnabled("update_workspace", r.enabledToolsets) { + tool := r.createDynamicTFETool("update_workspace", tfeTools.UpdateWorkspace) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Only register delete_workspace_safely if TF operations are enabled - if isTerraformOperationsEnabled() { - deleteWorkspaceSafelyTool := r.createDynamicTFETool("delete_workspace_safely", tfeTools.DeleteWorkspaceSafely) - r.mcpServer.AddTool(deleteWorkspaceSafelyTool.Tool, deleteWorkspaceSafelyTool.Handler) + // Only register delete_workspace_safely if TF operations are enabled AND toolset is enabled + if isTerraformOperationsEnabled() && toolsets.IsToolEnabled("delete_workspace_safely", r.enabledToolsets) { + tool := r.createDynamicTFETool("delete_workspace_safely", tfeTools.DeleteWorkspaceSafely) + r.mcpServer.AddTool(tool.Tool, tool.Handler) } - // Private provider tools - searchPrivateProvidersTool := r.createDynamicTFETool("search_private_providers", tfeTools.SearchPrivateProviders) - r.mcpServer.AddTool(searchPrivateProvidersTool.Tool, searchPrivateProvidersTool.Handler) + // Registry-private toolset - Private provider tools + if toolsets.IsToolEnabled("search_private_providers", r.enabledToolsets) { + tool := r.createDynamicTFETool("search_private_providers", tfeTools.SearchPrivateProviders) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - getPrivateProviderDetailsTool := r.createDynamicTFETool("get_private_provider_details", tfeTools.GetPrivateProviderDetails) - r.mcpServer.AddTool(getPrivateProviderDetailsTool.Tool, getPrivateProviderDetailsTool.Handler) + if toolsets.IsToolEnabled("get_private_provider_details", r.enabledToolsets) { + tool := r.createDynamicTFETool("get_private_provider_details", tfeTools.GetPrivateProviderDetails) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Private module tools - searchPrivateModulesTool := r.createDynamicTFETool("search_private_modules", tfeTools.SearchPrivateModules) - r.mcpServer.AddTool(searchPrivateModulesTool.Tool, searchPrivateModulesTool.Handler) + // Registry-private toolset - Private module tools + if toolsets.IsToolEnabled("search_private_modules", r.enabledToolsets) { + tool := r.createDynamicTFETool("search_private_modules", tfeTools.SearchPrivateModules) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - getPrivateModuleDetailsTool := r.createDynamicTFETool("get_private_module_details", tfeTools.GetPrivateModuleDetails) - r.mcpServer.AddTool(getPrivateModuleDetailsTool.Tool, getPrivateModuleDetailsTool.Handler) + if toolsets.IsToolEnabled("get_private_module_details", r.enabledToolsets) { + tool := r.createDynamicTFETool("get_private_module_details", tfeTools.GetPrivateModuleDetails) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Workspace tags tools - createWorkspaceTagsTool := r.createDynamicTFETool("create_workspace_tags", tfeTools.CreateWorkspaceTags) - r.mcpServer.AddTool(createWorkspaceTagsTool.Tool, createWorkspaceTagsTool.Handler) + // Terraform toolset - Workspace tags tools + if toolsets.IsToolEnabled("create_workspace_tags", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_workspace_tags", tfeTools.CreateWorkspaceTags) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - readWorkspaceTagsTool := r.createDynamicTFETool("read_workspace_tags", tfeTools.ReadWorkspaceTags) - r.mcpServer.AddTool(readWorkspaceTagsTool.Tool, readWorkspaceTagsTool.Handler) + if toolsets.IsToolEnabled("read_workspace_tags", r.enabledToolsets) { + tool := r.createDynamicTFETool("read_workspace_tags", tfeTools.ReadWorkspaceTags) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Terraform run tools - listRunsTool := r.createDynamicTFETool("list_runs", tfeTools.ListRuns) - r.mcpServer.AddTool(listRunsTool.Tool, listRunsTool.Handler) + // Terraform toolset - Run tools + if toolsets.IsToolEnabled("list_runs", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_runs", tfeTools.ListRuns) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } // Create run tool with conditional options based on TF operations setting - var createRunTool server.ServerTool - if isTerraformOperationsEnabled() { - createRunTool = r.createDynamicTFETool("create_run", tfeTools.CreateRun) - } else { - createRunTool = r.createDynamicTFETool("create_run", tfeTools.CreateRunSafe) + if toolsets.IsToolEnabled("create_run", r.enabledToolsets) { + var tool server.ServerTool + if isTerraformOperationsEnabled() { + tool = r.createDynamicTFETool("create_run", tfeTools.CreateRun) + } else { + tool = r.createDynamicTFETool("create_run", tfeTools.CreateRunSafe) + } + r.mcpServer.AddTool(tool.Tool, tool.Handler) } - r.mcpServer.AddTool(createRunTool.Tool, createRunTool.Handler) - // Only register action_run if TF operations are enabled - if isTerraformOperationsEnabled() { - actionRunTool := r.createDynamicTFETool("action_run", tfeTools.ActionRun) - r.mcpServer.AddTool(actionRunTool.Tool, actionRunTool.Handler) + // Only register action_run if TF operations are enabled AND toolset is enabled + if isTerraformOperationsEnabled() && toolsets.IsToolEnabled("action_run", r.enabledToolsets) { + tool := r.createDynamicTFETool("action_run", tfeTools.ActionRun) + r.mcpServer.AddTool(tool.Tool, tool.Handler) } - createNoCodeWorkspace := r.createDynamicTFEToolWithElicitation("create_no_code_workspace", tfeTools.CreateNoCodeWorkspace) - r.mcpServer.AddTool(createNoCodeWorkspace.Tool, createNoCodeWorkspace.Handler) + if toolsets.IsToolEnabled("create_no_code_workspace", r.enabledToolsets) { + tool := r.createDynamicTFEToolWithElicitation("create_no_code_workspace", tfeTools.CreateNoCodeWorkspace) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - getRunDetailsTool := r.createDynamicTFETool("get_run_details", tfeTools.GetRunDetails) - r.mcpServer.AddTool(getRunDetailsTool.Tool, getRunDetailsTool.Handler) + if toolsets.IsToolEnabled("get_run_details", r.enabledToolsets) { + tool := r.createDynamicTFETool("get_run_details", tfeTools.GetRunDetails) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Variable set tools - listVariableSetsTool := r.createDynamicTFETool("list_variable_sets", tfeTools.ListVariableSets) - r.mcpServer.AddTool(listVariableSetsTool.Tool, listVariableSetsTool.Handler) + // Terraform toolset - Variable set tools + if toolsets.IsToolEnabled("list_variable_sets", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_variable_sets", tfeTools.ListVariableSets) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - createVariableSetTool := r.createDynamicTFETool("create_variable_set", tfeTools.CreateVariableSet) - r.mcpServer.AddTool(createVariableSetTool.Tool, createVariableSetTool.Handler) + if toolsets.IsToolEnabled("create_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_variable_set", tfeTools.CreateVariableSet) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - createVariableInVariableSetTool := r.createDynamicTFETool("create_variable_in_variable_set", tfeTools.CreateVariableInVariableSet) - r.mcpServer.AddTool(createVariableInVariableSetTool.Tool, createVariableInVariableSetTool.Handler) + if toolsets.IsToolEnabled("create_variable_in_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_variable_in_variable_set", tfeTools.CreateVariableInVariableSet) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - deleteVariableInVariableSetTool := r.createDynamicTFETool("delete_variable_in_variable_set", tfeTools.DeleteVariableInVariableSet) - r.mcpServer.AddTool(deleteVariableInVariableSetTool.Tool, deleteVariableInVariableSetTool.Handler) + if toolsets.IsToolEnabled("delete_variable_in_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("delete_variable_in_variable_set", tfeTools.DeleteVariableInVariableSet) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } // Attach/detach variable sets to/from workspaces - attachVariableSetTool := r.createDynamicTFETool("attach_variable_set_to_workspaces", tfeTools.AttachVariableSetToWorkspaces) - r.mcpServer.AddTool(attachVariableSetTool.Tool, attachVariableSetTool.Handler) + if toolsets.IsToolEnabled("attach_variable_set_to_workspaces", r.enabledToolsets) { + tool := r.createDynamicTFETool("attach_variable_set_to_workspaces", tfeTools.AttachVariableSetToWorkspaces) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - detachVariableSetTool := r.createDynamicTFETool("detach_variable_set_from_workspaces", tfeTools.DetachVariableSetFromWorkspaces) - r.mcpServer.AddTool(detachVariableSetTool.Tool, detachVariableSetTool.Handler) + if toolsets.IsToolEnabled("detach_variable_set_from_workspaces", r.enabledToolsets) { + tool := r.createDynamicTFETool("detach_variable_set_from_workspaces", tfeTools.DetachVariableSetFromWorkspaces) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - // Variable tools - listWorkspaceVariablesTool := r.createDynamicTFETool("list_workspace_variables", tfeTools.ListWorkspaceVariables) - r.mcpServer.AddTool(listWorkspaceVariablesTool.Tool, listWorkspaceVariablesTool.Handler) + // Terraform toolset - Variable tools + if toolsets.IsToolEnabled("list_workspace_variables", r.enabledToolsets) { + tool := r.createDynamicTFETool("list_workspace_variables", tfeTools.ListWorkspaceVariables) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - createWorkspaceVariableTool := r.createDynamicTFETool("create_workspace_variable", tfeTools.CreateWorkspaceVariable) - r.mcpServer.AddTool(createWorkspaceVariableTool.Tool, createWorkspaceVariableTool.Handler) + if toolsets.IsToolEnabled("create_workspace_variable", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_workspace_variable", tfeTools.CreateWorkspaceVariable) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - updateWorkspaceVariableTool := r.createDynamicTFETool("update_workspace_variable", tfeTools.UpdateWorkspaceVariable) - r.mcpServer.AddTool(updateWorkspaceVariableTool.Tool, updateWorkspaceVariableTool.Handler) + if toolsets.IsToolEnabled("update_workspace_variable", r.enabledToolsets) { + tool := r.createDynamicTFETool("update_workspace_variable", tfeTools.UpdateWorkspaceVariable) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } r.tfeToolsRegistered = true } diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index da97de6a..008d1561 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -5,41 +5,60 @@ package tools import ( registryTools "github.com/hashicorp/terraform-mcp-server/pkg/tools/registry" + "github.com/hashicorp/terraform-mcp-server/pkg/toolsets" "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" ) -func RegisterTools(hcServer *server.MCPServer, logger *log.Logger) { - // Register the dynamic tool - registerDynamicTools(hcServer, logger) +func RegisterTools(hcServer *server.MCPServer, logger *log.Logger, enabledToolsets []string) { + // Register the dynamic tools (TFE tools that require authentication) + registerDynamicTools(hcServer, logger, enabledToolsets) - // Provider tools (always available) - getResolveProviderDocIDTool := registryTools.ResolveProviderDocID(logger) - hcServer.AddTool(getResolveProviderDocIDTool.Tool, getResolveProviderDocIDTool.Handler) + // Registry toolset - Provider tools + if toolsets.IsToolEnabled("search_providers", enabledToolsets) { + tool := registryTools.ResolveProviderDocID(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getProviderDocsTool := registryTools.GetProviderDocs(logger) - hcServer.AddTool(getProviderDocsTool.Tool, getProviderDocsTool.Handler) + if toolsets.IsToolEnabled("get_provider_details", enabledToolsets) { + tool := registryTools.GetProviderDocs(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getLatestProviderVersionTool := registryTools.GetLatestProviderVersion(logger) - hcServer.AddTool(getLatestProviderVersionTool.Tool, getLatestProviderVersionTool.Handler) + if toolsets.IsToolEnabled("get_latest_provider_version", enabledToolsets) { + tool := registryTools.GetLatestProviderVersion(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getProviderCapabilitiesTool := registryTools.GetProviderCapabilities(logger) - hcServer.AddTool(getProviderCapabilitiesTool.Tool, getProviderCapabilitiesTool.Handler) + if toolsets.IsToolEnabled("get_provider_capabilities", enabledToolsets) { + tool := registryTools.GetProviderCapabilities(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - // Module tools - getSearchModulesTool := registryTools.SearchModules(logger) - hcServer.AddTool(getSearchModulesTool.Tool, getSearchModulesTool.Handler) + // Registry toolset - Module tools + if toolsets.IsToolEnabled("search_modules", enabledToolsets) { + tool := registryTools.SearchModules(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getModuleDetailsTool := registryTools.ModuleDetails(logger) - hcServer.AddTool(getModuleDetailsTool.Tool, getModuleDetailsTool.Handler) + if toolsets.IsToolEnabled("get_module_details", enabledToolsets) { + tool := registryTools.ModuleDetails(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getLatestModuleVersionTool := registryTools.GetLatestModuleVersion(logger) - hcServer.AddTool(getLatestModuleVersionTool.Tool, getLatestModuleVersionTool.Handler) + if toolsets.IsToolEnabled("get_latest_module_version", enabledToolsets) { + tool := registryTools.GetLatestModuleVersion(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - // Policy tools - getSearchPoliciesTool := registryTools.SearchPolicies(logger) - hcServer.AddTool(getSearchPoliciesTool.Tool, getSearchPoliciesTool.Handler) + // Registry toolset - Policy tools + if toolsets.IsToolEnabled("search_policies", enabledToolsets) { + tool := registryTools.SearchPolicies(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } - getPolicyDetailsTool := registryTools.PolicyDetails(logger) - hcServer.AddTool(getPolicyDetailsTool.Tool, getPolicyDetailsTool.Handler) + if toolsets.IsToolEnabled("get_policy_details", enabledToolsets) { + tool := registryTools.PolicyDetails(logger) + hcServer.AddTool(tool.Tool, tool.Handler) + } } diff --git a/pkg/toolsets/mapping.go b/pkg/toolsets/mapping.go new file mode 100644 index 00000000..54c771f1 --- /dev/null +++ b/pkg/toolsets/mapping.go @@ -0,0 +1,70 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toolsets + +var ToolToToolset = map[string]string{ + // Public Registry tools (providers, modules, policies) + "search_providers": Registry, + "get_provider_details": Registry, + "get_latest_provider_version": Registry, + "get_provider_capabilities": Registry, + "search_modules": Registry, + "get_module_details": Registry, + "get_latest_module_version": Registry, + "search_policies": Registry, + "get_policy_details": Registry, + + // Private Registry tools (TFE/TFC private registry) + "search_private_modules": RegistryPrivate, + "get_private_module_details": RegistryPrivate, + "search_private_providers": RegistryPrivate, + "get_private_provider_details": RegistryPrivate, + + // Terraform tools (TFE/TFC workspaces, runs, variables, etc.) + "list_terraform_orgs": Terraform, + "list_terraform_projects": Terraform, + "list_workspaces": Terraform, + "get_workspace_details": Terraform, + "create_workspace": Terraform, + "create_no_code_workspace": Terraform, + "update_workspace": Terraform, + "delete_workspace_safely": Terraform, + "list_runs": Terraform, + "get_run_details": Terraform, + "create_run": Terraform, + "action_run": Terraform, + "list_workspace_variables": Terraform, + "create_workspace_variable": Terraform, + "update_workspace_variable": Terraform, + "list_variable_sets": Terraform, + "create_variable_set": Terraform, + "create_variable_in_variable_set": Terraform, + "delete_variable_in_variable_set": Terraform, + "attach_variable_set_to_workspaces": Terraform, + "detach_variable_set_from_workspaces": Terraform, + "create_workspace_tags": Terraform, + "read_workspace_tags": Terraform, +} + +// GetToolsetForTool returns the toolset name for a given tool name +func GetToolsetForTool(toolName string) (string, bool) { + toolset, exists := ToolToToolset[toolName] + return toolset, exists +} + +// IsToolEnabled checks if a tool is enabled based on the enabled toolsets +func IsToolEnabled(toolName string, enabledToolsets []string) bool { + if ContainsToolset(enabledToolsets, All) { + return true + } + + // Look up which toolset this tool belongs to + toolset, exists := GetToolsetForTool(toolName) + if !exists { + return false + } + + // Check if the tool's toolset is enabled + return ContainsToolset(enabledToolsets, toolset) +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go new file mode 100644 index 00000000..82efe6d7 --- /dev/null +++ b/pkg/toolsets/toolsets.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toolsets + +import "strings" + +const ( + // Core toolsets + Registry = "registry" + RegistryPrivate = "registry-private" // Private registry (TFE/TFC) + Terraform = "terraform" // TFE/TFC operations + + // Special toolsets + All = "all" + Default = "default" +) + +// Toolset represents metadata about a toolset +type Toolset struct { + Name string + Description string +} + +var ( + AllToolset = Toolset{ + Name: All, + Description: "Special toolset that enables all available toolsets", + } + DefaultToolset = Toolset{ + Name: Default, + Description: "Special toolset that enables the default toolset configuration", + } + RegistryToolset = Toolset{ + Name: Registry, + Description: "Public Terraform Registry (providers, modules, policies)", + } + RegistryPrivateToolset = Toolset{ + Name: RegistryPrivate, + Description: "Private registry access (TFE/TFC private modules and providers)", + } + TerraformToolset = Toolset{ + Name: Terraform, + Description: "HCP Terraform/TFE operations (workspaces, runs, variables, etc.)", + } +) + +func AvailableToolsets() []Toolset { + return []Toolset{ + RegistryToolset, + RegistryPrivateToolset, + TerraformToolset, + } +} + +// DefaultToolsets returns the default set of enabled toolsets +func DefaultToolsets() []string { + return []string{Registry} +} + +func GetValidToolsetNames() map[string]bool { + validNames := make(map[string]bool) + for _, ts := range AvailableToolsets() { + validNames[ts.Name] = true + } + validNames[AllToolset.Name] = true + validNames[DefaultToolset.Name] = true + return validNames +} + +func CleanToolsets(enabledToolsets []string) ([]string, []string) { + seen := make(map[string]bool) + result := make([]string, 0, len(enabledToolsets)) + invalid := make([]string, 0) + validNames := GetValidToolsetNames() + + for _, toolset := range enabledToolsets { + trimmed := strings.TrimSpace(toolset) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + result = append(result, trimmed) + if !validNames[trimmed] { + invalid = append(invalid, trimmed) + } + } + } + + return result, invalid +} + +func ExpandDefaultToolset(toolsets []string) []string { + hasDefault := false + seen := make(map[string]bool) + + for _, ts := range toolsets { + seen[ts] = true + if ts == Default { + hasDefault = true + } + } + + if !hasDefault { + return toolsets + } + + result := make([]string, 0, len(toolsets)) + for _, ts := range toolsets { + if ts != Default { + result = append(result, ts) + } + } + + for _, defaultTS := range DefaultToolsets() { + if !seen[defaultTS] { + result = append(result, defaultTS) + } + } + + return result +} + +// ContainsToolset checks if a toolset is in the list +func ContainsToolset(toolsets []string, toCheck string) bool { + for _, ts := range toolsets { + if ts == toCheck { + return true + } + } + return false +} + +// GenerateToolsetsHelp generates help text for the toolsets flag +func GenerateToolsetsHelp() string { + defaultTools := strings.Join(DefaultToolsets(), ", ") + + allToolsets := AvailableToolsets() + var toolsetNames []string + for _, ts := range allToolsets { + toolsetNames = append(toolsetNames, ts.Name) + } + availableTools := strings.Join(toolsetNames, ", ") + + return "Comma-separated list of tool groups to enable.\n" + + "Available: " + availableTools + "\n" + + "Special toolset keywords:\n" + + " - all: Enables all available toolsets\n" + + " - default: Enables the default toolset configuration (" + defaultTools + ")\n" + + "Examples:\n" + + " - --toolsets=registry,terraform\n" + + " - --toolsets=default,registry-private\n" + + " - --toolsets=all" +} diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go new file mode 100644 index 00000000..3f4276fa --- /dev/null +++ b/pkg/toolsets/toolsets_test.go @@ -0,0 +1,229 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toolsets + +import ( + "reflect" + "testing" +) + +func TestCleanToolsets(t *testing.T) { + tests := []struct { + name string + input []string + expectedValid []string + expectedInvalid []string + }{ + { + name: "valid toolsets", + input: []string{"registry", "terraform"}, + expectedValid: []string{"registry", "terraform"}, + expectedInvalid: []string{}, + }, + { + name: "invalid toolsets", + input: []string{"invalid", "fake"}, + expectedValid: []string{"invalid", "fake"}, + expectedInvalid: []string{"invalid", "fake"}, + }, + { + name: "mixed valid and invalid", + input: []string{"registry", "invalid", "terraform"}, + expectedValid: []string{"registry", "invalid", "terraform"}, + expectedInvalid: []string{"invalid"}, + }, + { + name: "empty strings", + input: []string{"registry", "", "terraform", " "}, + expectedValid: []string{"registry", "terraform"}, + expectedInvalid: []string{}, + }, + { + name: "duplicates", + input: []string{"registry", "registry", "terraform"}, + expectedValid: []string{"registry", "terraform"}, + expectedInvalid: []string{}, + }, + { + name: "whitespace trimming", + input: []string{" registry ", " terraform "}, + expectedValid: []string{"registry", "terraform"}, + expectedInvalid: []string{}, + }, + { + name: "special toolsets", + input: []string{"all", "default"}, + expectedValid: []string{"all", "default"}, + expectedInvalid: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid, invalid := CleanToolsets(tt.input) + + if !reflect.DeepEqual(valid, tt.expectedValid) { + t.Errorf("CleanToolsets() valid = %v, want %v", valid, tt.expectedValid) + } + + if !reflect.DeepEqual(invalid, tt.expectedInvalid) { + t.Errorf("CleanToolsets() invalid = %v, want %v", invalid, tt.expectedInvalid) + } + }) + } +} + +func TestExpandDefaultToolset(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no default keyword", + input: []string{"registry", "terraform"}, + expected: []string{"registry", "terraform"}, + }, + { + name: "default keyword only", + input: []string{"default"}, + expected: []string{"registry"}, + }, + { + name: "default with additional toolsets", + input: []string{"default", "terraform"}, + expected: []string{"terraform", "registry"}, + }, + { + name: "default with registry already included", + input: []string{"default", "registry", "terraform"}, + expected: []string{"registry", "terraform"}, + }, + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandDefaultToolset(tt.input) + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ExpandDefaultToolset() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestContainsToolset(t *testing.T) { + tests := []struct { + name string + toolsets []string + toCheck string + expected bool + }{ + { + name: "toolset present", + toolsets: []string{"registry", "terraform"}, + toCheck: "registry", + expected: true, + }, + { + name: "toolset not present", + toolsets: []string{"registry", "terraform"}, + toCheck: "registry-private", + expected: false, + }, + { + name: "empty list", + toolsets: []string{}, + toCheck: "registry", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ContainsToolset(tt.toolsets, tt.toCheck) + + if result != tt.expected { + t.Errorf("ContainsToolset() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetValidToolsetNames(t *testing.T) { + validNames := GetValidToolsetNames() + + // Check that all expected toolsets are present + expected := []string{"registry", "registry-private", "terraform", "all", "default"} + for _, name := range expected { + if !validNames[name] { + t.Errorf("GetValidToolsetNames() missing expected toolset: %s", name) + } + } + + if len(validNames) != len(expected) { + t.Errorf("GetValidToolsetNames() returned %d toolsets, want %d", len(validNames), len(expected)) + } +} + +func TestIsToolEnabled(t *testing.T) { + tests := []struct { + name string + toolName string + enabledToolsets []string + expected bool + }{ + { + name: "tool enabled - registry", + toolName: "search_providers", + enabledToolsets: []string{"registry"}, + expected: true, + }, + { + name: "tool disabled", + toolName: "search_providers", + enabledToolsets: []string{"terraform"}, + expected: false, + }, + { + name: "all toolset enables everything", + toolName: "search_providers", + enabledToolsets: []string{"all"}, + expected: true, + }, + { + name: "unknown tool", + toolName: "unknown_tool", + enabledToolsets: []string{"registry"}, + expected: false, + }, + { + name: "terraform tool", + toolName: "list_workspaces", + enabledToolsets: []string{"terraform"}, + expected: true, + }, + { + name: "private registry tool", + toolName: "search_private_modules", + enabledToolsets: []string{"registry-private"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsToolEnabled(tt.toolName, tt.enabledToolsets) + + if result != tt.expected { + t.Errorf("IsToolEnabled(%s, %v) = %v, want %v", tt.toolName, tt.enabledToolsets, result, tt.expected) + } + }) + } +}