diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7624bb8..0afd333c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,7 @@ jobs: outputs: go-version: ${{ steps.get-go-version.outputs.go-version }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Determine Go version id: get-go-version # We use .go-version as our source of truth for current Go @@ -65,7 +65,7 @@ jobs: product-prerelease-version: ${{ steps.set-product-version.outputs.prerelease-product-version }} product-minor-version: ${{ steps.set-product-version.outputs.minor-product-version }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set Product version id: set-product-version uses: hashicorp/actions-set-product-version@v2 @@ -77,7 +77,7 @@ jobs: filepath: ${{ steps.generate-metadata-file.outputs.filepath }} steps: - name: "Checkout directory" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Generate metadata file id: generate-metadata-file uses: hashicorp/actions-generate-metadata@v1 @@ -85,7 +85,7 @@ jobs: version: ${{ needs.set-product-version.outputs.product-version }} product: ${{ env.PKG_NAME }} repositoryOwner: "hashicorp" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: metadata.json path: ${{ steps.generate-metadata-file.outputs.filepath }} @@ -114,7 +114,7 @@ jobs: name: Go ${{ needs.get-go-version.outputs.go-version }} ${{ matrix.goos }} ${{ matrix.goarch }} build steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: hashicorp/actions-go-build@v1 env: @@ -152,7 +152,7 @@ jobs: repo: ${{ github.event.repository.name }} product_version: ${{ needs.set-product-version.outputs.product-version }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Docker Build (Action) uses: hashicorp/actions-docker-build@v2 with: diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index b3e3f3c8..23dd5bbd 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c with: go-version-file: "go.mod" diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml new file mode 100644 index 00000000..9f7b5bed --- /dev/null +++ b/.github/workflows/publish-registry.yml @@ -0,0 +1,54 @@ +name: publish-registry + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Validate triggering actor + env: + ALLOWED_ACTORS: | + gautambaghel + jrhouston + jaylonmcshan19-x + run: | + if ! grep -Fxq "${GITHUB_ACTOR}" <<< "${ALLOWED_ACTORS}"; then + echo "github.actor '${GITHUB_ACTOR}' is not authorized to run this workflow." + exit 1 + fi + echo "Authorized actor: ${GITHUB_ACTOR}" + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Set up Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version-file: .go-version + cache: true + cache-dependency-path: | + go.sum + + - name: Run unit tests + run: go test ./... + + - name: Build release artifacts + run: make crt-build + + - name: Install MCP Publisher CLI + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname%20-s%20|%20tr'[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + chmod +x mcp-publisher + + - name: Authenticate with MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish server to MCP Registry + run: ./mcp-publisher publish diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 00000000..0d3e2b52 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,27 @@ +name: Version Consistency Check + +on: + push: + branches: + - 'release/*' + pull_request: + branches: + - 'release/*' + +jobs: + version-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Install jq + run: | + sudo apt-get update && sudo apt-get install -y jq + + - name: Make version check script executable + run: chmod +x scripts/compare-versions.sh + + - name: Run version consistency check + run: ./scripts/compare-versions.sh diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 9d26518f..011e89bc 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -48,17 +48,17 @@ jobs: security-events: write steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: path: code - - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: cache: ${{ contains(runner.name, 'Github Actions') }} go-version-file: "code/.go-version" cache-dependency-path: '**/go.sum' - name: Clone Security Scanner repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: hashicorp/security-scanner token: ${{ secrets.PRODSEC_SCANNER_READ_ONLY }} diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 2598661c..f47e9721 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c with: go-version-file: "go.mod" diff --git a/.gitignore b/.gitignore index 50ec4b29..57e23dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ cmd/mcpcurl/mcpcurl .vscode/ dist/ bin/* +server.json.backup* +gemini-extension.json.backup* diff --git a/.go-version b/.go-version index 8407e260..26a9e99b 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.24.7 +1.25.4 diff --git a/.release/ci.hcl b/.release/ci.hcl index 821a9e77..c8f725ac 100644 --- a/.release/ci.hcl +++ b/.release/ci.hcl @@ -8,7 +8,7 @@ project "terraform-mcp-server" { # slack channel : feed-terraform-mcp-server-releases slack { - notification_channel = "C08TEJWRXDX" + notification_channel = "C09KWKM9HHB" } github { diff --git a/CHANGELOG.md b/CHANGELOG.md index e020491c..1b1a84b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,35 @@ FEATURES -* Adding provider capability discovery tool to analyze available resources, data sources, functions, guides, and actions +* **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. + +FIXES + +* Skip TLS flag was not propogated properly [243](https://github.com/hashicorp/terraform-mcp-server/issues/243) + +## 0.3.3 (Nov 21, 2025) + +IMPROVEMENTS + +* Adding support for searching Terraform List Resources documentation + +## 0.3.2 (Oct 23, 2025) + +FEATURES + +* [New Tool] `get_provider_capabilities` Adding provider capability discovery tool to analyze available resources, data sources, functions, guides, and actions + +* [New Tool] `create_no_code_workspace` Adding capability to trigger a workspace run using a no code module FIXES * Added a module id validator to fix issue [182](https://github.com/hashicorp/terraform-mcp-server/issues/182) * Fixes in readme for `TFE_HOSTNAME` v/s `TFE_ADDRESS` +IMPROVEMENTS + +* Added official MCP Registry Server JSON Specification file [server.json](server.json) to the repo. See [#200](https://github.com/hashicorp/terraform-mcp-server/pull/200) + ## 0.3.1 (Oct 3, 2025) FEATURES diff --git a/Dockerfile b/Dockerfile index 36224fda..1335b274 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,12 @@ # =================================== # certbuild captures the ca-certificates -FROM docker.mirror.hashicorp.services/alpine:3.22 AS certbuild +FROM docker.mirror.hashicorp.services/alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 AS certbuild RUN apk add --no-cache ca-certificates # devbuild compiles the binary # ----------------------------------- -FROM golang:1.24.6-alpine@sha256:c8c5f95d64aa79b6547f3b626eb84b16a7ce18a139e3e9ca19a8c078b85ba80d AS devbuild +FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS devbuild ARG VERSION="dev" # Set the working directory WORKDIR /build @@ -61,6 +61,7 @@ ARG PRODUCT_NAME=$BIN_NAME ARG TARGETOS TARGETARCH LABEL version=$PRODUCT_VERSION LABEL revision=$PRODUCT_REVISION +LABEL io.modelcontextprotocol.server.name="io.github.hashicorp/terraform-mcp-server" COPY dist/$TARGETOS/$TARGETARCH/$BIN_NAME /bin/terraform-mcp-server COPY --from=certbuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # Command to run the server (mode determined by environment variables or defaults to stdio) diff --git a/Makefile b/Makefile index 1e7c3b61..b794b0c3 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ TARGET_DIR ?= $(CURDIR)/dist # Build flags LDFLAGS=-ldflags="-s -w -X terraform-mcp-server/version.GitCommit=$(shell git rev-parse HEAD) -X terraform-mcp-server/version.BuildDate=$(shell git show --no-show-signature -s --format=%cd --date=format:"%Y-%m-%dT%H:%M:%SZ" HEAD)" -.PHONY: all build crt-build test test-e2e test-security clean deps docker-build run-http run-http-secure docker-run-http test-http cleanup-test-containers help +.PHONY: all build crt-build test test-e2e test-security clean deps docker-build run-http run-http-secure docker-run-http test-http cleanup-test-containers update-server-json-version help # Default target all: build @@ -63,6 +63,19 @@ run-http-secure: docker-run-http: $(DOCKER) run -p 8080:8080 --rm $(BINARY_NAME):$(VERSION) http --transport-port 8080 --transport-host 0.0.0.0 +# Synchronise server.json version fields with version/VERSION +update-json-version: + @VERSION_FILE="$(CURDIR)/version/VERSION"; \ + SERVER_JSON="$(CURDIR)/server.json"; \ + "$(CURDIR)/scripts/update-json-version.sh" "$$SERVER_JSON" "$$VERSION_FILE" + + +# Synchronise gemini-extension.json version fields with version/VERSION +update-gemini-version: + @VERSION_FILE="$(CURDIR)/version/VERSION"; \ + SERVER_JSON="$(CURDIR)/gemini-extension.json"; \ + "$(CURDIR)/scripts/update-json-version.sh" "$$SERVER_JSON" "$$VERSION_FILE" + # Test HTTP endpoint test-http: @echo "Testing StreamableHTTP server health endpoint..." @@ -87,18 +100,20 @@ cleanup-test-containers: # Show help help: @echo "Available targets:" - @echo " all - Build the binary (default)" - @echo " build - Build the binary" - @echo " test - Run all tests" - @echo " test-e2e - Run end-to-end tests" - @echo " test-security - Run security-related tests" - @echo " clean - Remove build artifacts" - @echo " deps - Download dependencies" - @echo " docker-build - Build docker image" - @echo " run-http - Run StreamableHTTP server locally on port 8080" - @echo " run-http-secure - Run StreamableHTTP server with security settings" - @echo " docker-run-http - Run StreamableHTTP server in Docker on port 8080" - @echo " test-http - Test StreamableHTTP health endpoint" + @echo " all - Build the binary (default)" + @echo " build - Build the binary" + @echo " crt-build - Build using crt-build script" + @echo " test - Run all tests" + @echo " test-e2e - Run end-to-end tests" + @echo " test-security - Run security-related tests" + @echo " test-http - Test StreamableHTTP health endpoint" + @echo " clean - Remove build artifacts" + @echo " deps - Download dependencies" + @echo " docker-build - Build docker image" + @echo " run-http - Run StreamableHTTP server locally on port 8080" + @echo " run-http-secure - Run StreamableHTTP server with security settings" + @echo " docker-run-http - Run StreamableHTTP server in Docker on port 8080" + @echo " update-json-version - Update server.json to match version/VERSION" + @echo " update-gemini-version - Update gemini-extension.json to match version/VERSION" @echo " cleanup-test-containers - Stop and remove all test containers" - @echo " help - Show this help message" - + @echo " help - Show this help message" diff --git a/README.md b/README.md index 7202b347..f82d723e 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ More about using MCP server tools in VS Code's [agent mode documentation](https: "--rm", "-e", "TFE_TOKEN=${input:tfe_token}", "-e", "TFE_ADDRESS=${input:tfe_address}", - "hashicorp/terraform-mcp-server:0.3.0" + "hashicorp/terraform-mcp-server:0.3.3" ] } }, @@ -149,7 +149,7 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c "--rm", "-e", "TFE_TOKEN=${input:tfe_token}", "-e", "TFE_ADDRESS=${input:tfe_address}", - "hashicorp/terraform-mcp-server:0.3.0" + "hashicorp/terraform-mcp-server:0.3.3" ] } }, @@ -216,7 +216,7 @@ Add this to your Cursor config (`~/.cursor/mcp.json`) or via Settings → Cursor "--rm", "-e", "TFE_ADDRESS=<>", "-e", "TFE_TOKEN=<>", - "hashicorp/terraform-mcp-server:0.3.0" + "hashicorp/terraform-mcp-server:0.3.3" ] } } @@ -269,7 +269,7 @@ More about using MCP server tools in Claude Desktop [user documentation](https:/ "--rm", "-e", "TFE_ADDRESS=<>", "-e", "TFE_TOKEN=<>", - "hashicorp/terraform-mcp-server:0.3.0" + "hashicorp/terraform-mcp-server:0.3.3" ] } } 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 1674accf..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) @@ -55,6 +56,7 @@ func NewServer(version string, logger *log.Logger, opts ...server.ServerOption) server.WithResourceCapabilities(true, true), server.WithInstructions(instructions), server.WithToolHandlerMiddleware(rateLimitMiddleware.Middleware()), + server.WithElicitation(), } opts = append(defaultOpts, opts...) @@ -79,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 @@ -91,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) } } @@ -109,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/e2e/e2e_test.go b/e2e/e2e_test.go index 648f6675..9a461462 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -126,6 +126,8 @@ func runTestSuite(t *testing.T, client mcpClient.MCPClient, transportName string require.Contains(t, textContent.Text, "functions", "expected content to contain functions") case CONST_TYPE_ACTIONS: require.Contains(t, textContent.Text, "actions", "expected content to contain actions") + case CONST_TYPE_LIST_RESOURCES: + require.Contains(t, textContent.Text, "list-resources", "expected content to contain list-resources") } } }) diff --git a/e2e/payloads.go b/e2e/payloads.go index d1697e97..bbce6a1c 100644 --- a/e2e/payloads.go +++ b/e2e/payloads.go @@ -6,12 +6,13 @@ package e2e type ContentType string const ( - CONST_TYPE_RESOURCE ContentType = "resources" - CONST_TYPE_DATA_SOURCE ContentType = "data-sources" - CONST_TYPE_GUIDES ContentType = "guides" - CONST_TYPE_FUNCTIONS ContentType = "functions" - CONST_TYPE_OVERVIEW ContentType = "overview" - CONST_TYPE_ACTIONS ContentType = "actions" + CONST_TYPE_RESOURCE ContentType = "resources" + CONST_TYPE_DATA_SOURCE ContentType = "data-sources" + CONST_TYPE_GUIDES ContentType = "guides" + CONST_TYPE_FUNCTIONS ContentType = "functions" + CONST_TYPE_OVERVIEW ContentType = "overview" + CONST_TYPE_ACTIONS ContentType = "actions" + CONST_TYPE_LIST_RESOURCES ContentType = "list-resources" ) type RegistryTestCase struct { @@ -185,6 +186,19 @@ var searchProviderTestCases = []RegistryTestCase{ "service_slug": "ec2", }, }, + { + TestName: "list_resources_documentation", + TestShouldFail: false, + TestDescription: "Testing search_providers list-resources documentation with v2 API", + TestContentType: CONST_TYPE_LIST_RESOURCES, + TestPayload: map[string]interface{}{ + "provider_name": "aws", + "provider_namespace": "hashicorp", + "provider_version": "latest", + "provider_document_type": "list-resources", + "service_slug": "instance", + }, + }, } var providerDetailsTestCases = []RegistryTestCase{ @@ -409,7 +423,7 @@ var searchPoliciesTestCases = []RegistryTestCase{ TestShouldFail: false, TestDescription: "Testing search_policies with policy name containing spaces", TestPayload: map[string]interface{}{ - "policy_query": "FSBP Foundations benchmark", + "policy_query": "Foundational Security Best Practices(FSBP)", }, }, } diff --git a/gemini-extension.json b/gemini-extension.json index 99f981af..7f7d4166 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "terraform", - "version": "0.3.1", + "version": "0.3.3", "contextFileName": "${extensionPath}${/}instructions${/}example-AGENTS.md", "mcpServers": { "terraform": { @@ -13,11 +13,14 @@ "TFE_TOKEN", "-e", "TFE_ADDRESS", - "hashicorp/terraform-mcp-server:0.3.1" + "-e", + "ENABLE_TF_OPERATIONS", + "hashicorp/terraform-mcp-server:0.3.3" ], "env": { "TFE_TOKEN": "$TFE_TOKEN", - "TFE_ADDRESS": "$TFE_ADDRESS" + "TFE_ADDRESS": "$TFE_ADDRESS", + "ENABLE_TF_OPERATIONS": "$ENABLE_TF_OPERATIONS" } } } diff --git a/go.mod b/go.mod index 60aa671a..13d8686e 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/hashicorp/terraform-mcp-server -go 1.24.0 +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.93.0 + github.com/hashicorp/go-tfe v1.97.0 github.com/hashicorp/jsonapi v1.5.0 - github.com/mark3labs/mcp-go v0.41.1 + 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 @@ -21,18 +21,16 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/go-slug v0.16.7 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-slug v0.16.8 // 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.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -40,8 +38,9 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.32.0 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9d9d4ad1..dbf899af 100644 --- a/go.sum +++ b/go.sum @@ -28,28 +28,31 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= -github.com/hashicorp/go-slug v0.16.7 h1:sBW8y1sX+JKOZKu9a+DQZuWDVaX+U9KFnk6+VDQvKcw= -github.com/hashicorp/go-slug v0.16.7/go.mod h1:X5fm++dL59cDOX8j48CqHr4KARTQau7isGh0ZVxJB5I= -github.com/hashicorp/go-tfe v1.93.0 h1:hJubwn1xNCo1iBO66iQkjyC+skR61cK1AQUj4O9vvuI= -github.com/hashicorp/go-tfe v1.93.0/go.mod h1:QwqgCD5seztgp76CP7F0POJPflQNSqjIvBpVohg9X50= +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.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= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= -github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +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.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= @@ -62,18 +65,16 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 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= @@ -91,19 +92,19 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/client/common.go b/pkg/client/common.go index fb292f4f..acd7aece 100644 --- a/pkg/client/common.go +++ b/pkg/client/common.go @@ -90,7 +90,10 @@ func GetProviderResourceDocs(httpClient *http.Client, providerDocsID string, log func parseTerraformSkipTLSVerify(ctx context.Context) bool { terraformSkipTLSVerifyStr, ok := ctx.Value(contextKey(TerraformSkipTLSVerify)).(string) - if ok && terraformSkipTLSVerifyStr != "" { + if !ok || terraformSkipTLSVerifyStr == "" { + terraformSkipTLSVerifyStr = utils.GetEnv(TerraformSkipTLSVerify, "") + } + if terraformSkipTLSVerifyStr != "" { terraformSkipTLSVerify, err := strconv.ParseBool(terraformSkipTLSVerifyStr) if err == nil { return terraformSkipTLSVerify diff --git a/pkg/client/types.go b/pkg/client/types.go index de87145f..491d717f 100644 --- a/pkg/client/types.go +++ b/pkg/client/types.go @@ -446,3 +446,25 @@ type WorkspaceToolResponse struct { Variables []*tfe.Variable `jsonapi:"polyrelation,variables,omitempty"` Readme string `jsonapi:"attr,readme,omitempty"` } + +type ModuleMetadata struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + GitRefTag string `json:"git-ref-tag"` + GitRepoURL string `json:"git-repo-url"` + InputVariables []struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + } `json:"input-variables"` + Name string `json:"name"` + SourceURL string `json:"source-url"` + Version string `json:"version"` + NoCode bool `json:"no-code"` + } `json:"attributes"` + } `json:"data"` +} diff --git a/pkg/tools/dynamic_tool.go b/pkg/tools/dynamic_tool.go index a5d67ad1..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,105 +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) } - getRunDetailsTool := r.createDynamicTFETool("get_run_details", tfeTools.GetRunDetails) - r.mcpServer.AddTool(getRunDetailsTool.Tool, getRunDetailsTool.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) + } - // Variable set tools - listVariableSetsTool := r.createDynamicTFETool("list_variable_sets", tfeTools.ListVariableSets) - r.mcpServer.AddTool(listVariableSetsTool.Tool, listVariableSetsTool.Handler) + if toolsets.IsToolEnabled("get_run_details", r.enabledToolsets) { + tool := r.createDynamicTFETool("get_run_details", tfeTools.GetRunDetails) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - createVariableSetTool := r.createDynamicTFETool("create_variable_set", tfeTools.CreateVariableSet) - r.mcpServer.AddTool(createVariableSetTool.Tool, createVariableSetTool.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) + } - createVariableInVariableSetTool := r.createDynamicTFETool("create_variable_in_variable_set", tfeTools.CreateVariableInVariableSet) - r.mcpServer.AddTool(createVariableInVariableSetTool.Tool, createVariableInVariableSetTool.Handler) + if toolsets.IsToolEnabled("create_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_variable_set", tfeTools.CreateVariableSet) + 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("create_variable_in_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_variable_in_variable_set", tfeTools.CreateVariableInVariableSet) + 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("delete_variable_in_variable_set", r.enabledToolsets) { + tool := r.createDynamicTFETool("delete_variable_in_variable_set", tfeTools.DeleteVariableInVariableSet) + r.mcpServer.AddTool(tool.Tool, tool.Handler) + } - detachVariableSetTool := r.createDynamicTFETool("detach_variable_set_from_workspaces", tfeTools.DetachVariableSetFromWorkspaces) - r.mcpServer.AddTool(detachVariableSetTool.Tool, detachVariableSetTool.Handler) + // Attach/detach variable sets to/from workspaces + 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) + } - // Variable tools - listWorkspaceVariablesTool := r.createDynamicTFETool("list_workspace_variables", tfeTools.ListWorkspaceVariables) - r.mcpServer.AddTool(listWorkspaceVariablesTool.Tool, listWorkspaceVariablesTool.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) + } - createWorkspaceVariableTool := r.createDynamicTFETool("create_workspace_variable", tfeTools.CreateWorkspaceVariable) - r.mcpServer.AddTool(createWorkspaceVariableTool.Tool, createWorkspaceVariableTool.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) + } - updateWorkspaceVariableTool := r.createDynamicTFETool("update_workspace_variable", tfeTools.UpdateWorkspaceVariable) - r.mcpServer.AddTool(updateWorkspaceVariableTool.Tool, updateWorkspaceVariableTool.Handler) + if toolsets.IsToolEnabled("create_workspace_variable", r.enabledToolsets) { + tool := r.createDynamicTFETool("create_workspace_variable", tfeTools.CreateWorkspaceVariable) + r.mcpServer.AddTool(tool.Tool, tool.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 } @@ -208,9 +263,24 @@ func (r *DynamicToolRegistry) registerTFETools() { // createDynamicTFETool creates a TFE tool with dynamic availability checking func (r *DynamicToolRegistry) createDynamicTFETool(toolName string, toolFactory func(*log.Logger) server.ServerTool) server.ServerTool { originalTool := toolFactory(r.logger) + return server.ServerTool{ + Tool: originalTool.Tool, + Handler: r.wrapWithAvailabilityCheck(toolName, originalTool.Handler), + } +} + +// createDynamicTFEToolWithElicitation creates a TFE tool with dynamic availability checking that also needs MCPServer for elicitation +func (r *DynamicToolRegistry) createDynamicTFEToolWithElicitation(toolName string, toolFactory func(*log.Logger, *server.MCPServer) server.ServerTool) server.ServerTool { + originalTool := toolFactory(r.logger, r.mcpServer) + return server.ServerTool{ + Tool: originalTool.Tool, + Handler: r.wrapWithAvailabilityCheck(toolName, originalTool.Handler), + } +} - // Wrap the handler with dynamic availability checking - wrappedHandler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// wrapWithAvailabilityCheck wraps a tool handler with dynamic TFE availability checking +func (r *DynamicToolRegistry) wrapWithAvailabilityCheck(toolName string, originalHandler server.ToolHandlerFunc) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get session from context session := server.ClientSessionFromContext(ctx) if session == nil { @@ -235,11 +305,6 @@ func (r *DynamicToolRegistry) createDynamicTFETool(toolName string, toolFactory } // Tool is available, proceed with original handler - return originalTool.Handler(ctx, req) - } - - return server.ServerTool{ - Tool: originalTool.Tool, - Handler: wrappedHandler, + return originalHandler(ctx, req) } } diff --git a/pkg/tools/registry/get_provider_capabilities.go b/pkg/tools/registry/get_provider_capabilities.go index b584b6d7..81c4485f 100644 --- a/pkg/tools/registry/get_provider_capabilities.go +++ b/pkg/tools/registry/get_provider_capabilities.go @@ -32,7 +32,7 @@ This tool analyzes the provider documentation to determine what types of capabil - guides: Documentation guides and tutorials for using the provider - actions: Available provider actions (if any) - ephemeral resources: Temporary resources for credentials and tokens -- list resources: Resources for listing multiple items of specific types +- list-resources: List resources for querying existing cloud resources (Terraform Search) Returns a summary with counts and examples for each capability type.`), mcp.WithTitleAnnotation("Get Terraform provider capabilities and supported features"), diff --git a/pkg/tools/registry/get_provider_capabilities_test.go b/pkg/tools/registry/get_provider_capabilities_test.go index c4ff42cf..8eb19866 100644 --- a/pkg/tools/registry/get_provider_capabilities_test.go +++ b/pkg/tools/registry/get_provider_capabilities_test.go @@ -18,6 +18,7 @@ func TestAnalyzeAndFormatCapabilities(t *testing.T) { {Category: "data-sources", Title: "aws_ami", Language: "hcl"}, {Category: "functions", Title: "base64encode", Language: "hcl"}, {Category: "guides", Title: "Getting Started", Language: "hcl"}, + {Category: "list-resources", Title: "aws_instance", Language: "hcl"}, }, } diff --git a/pkg/tools/registry/search_providers.go b/pkg/tools/registry/search_providers.go index a134ff20..d4f20cf8 100644 --- a/pkg/tools/registry/search_providers.go +++ b/pkg/tools/registry/search_providers.go @@ -54,8 +54,9 @@ for general overview of the provider use 'overview', for guidance on upgrading a provider or custom configuration information use 'guides', for deploying resources use 'resources', for reading pre-deployed resources use 'data-sources', for functions use 'functions', -for Terraform actions use 'actions'`), - mcp.Enum("resources", "data-sources", "functions", "guides", "overview", "actions"), +for Terraform actions use 'actions', +for listing resources using Terraform Search use 'list-resources'`), + mcp.Enum("resources", "data-sources", "functions", "guides", "overview", "actions", "list-resources"), ), mcp.WithString("provider_version", mcp.Description("The version of the Terraform provider to retrieve in the format 'x.y.z', or 'latest' to get the latest version")), diff --git a/pkg/tools/tfe/create_no_code_workspace.go b/pkg/tools/tfe/create_no_code_workspace.go new file mode 100644 index 00000000..cadf5e41 --- /dev/null +++ b/pkg/tools/tfe/create_no_code_workspace.go @@ -0,0 +1,382 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path" + "strconv" + "strings" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-mcp-server/pkg/client" + "github.com/hashicorp/terraform-mcp-server/pkg/utils" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + log "github.com/sirupsen/logrus" +) + +// CreateNoCodeWorkspace creates a tool to create a No Code module workspace. +func CreateNoCodeWorkspace(logger *log.Logger, mcpServer *server.MCPServer) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("create_no_code_workspace", + mcp.WithDescription(`Creates a new Terraform No Code module workspace. The tool uses the MCP elicitation feature to automatically discover and collect required variables from the user.`), + mcp.WithTitleAnnotation("Create a No Code module workspace"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithString("no_code_module_id", + mcp.Required(), + mcp.Description("The ID of the No Code module to create a workspace for"), + ), + mcp.WithString("workspace_name", + mcp.Required(), + mcp.Description("The name of the workspace to create"), + ), + mcp.WithString("project_id", + mcp.Required(), + mcp.Description("The ID of the project to use"), + ), + mcp.WithBoolean("auto_apply", + mcp.Description("Whether to automatically apply changes in the workspace: 'true' or 'false'"), + mcp.DefaultBool(false), + ), + ), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return createNoCodeWorkspaceHandler(ctx, req, logger, mcpServer) + }, + } +} + +func createNoCodeWorkspaceHandler(ctx context.Context, request mcp.CallToolRequest, logger *log.Logger, mcpServer *server.MCPServer) (*mcp.CallToolResult, error) { + tfeClient, err := client.GetTfeClientFromContext(ctx, logger) + if err != nil { + return nil, utils.LogAndReturnError(logger, "getting Terraform client", err) + } + if tfeClient == nil { + return nil, utils.LogAndReturnError(logger, "getting Terraform client - please ensure TFE_TOKEN and TFE_ADDRESS are properly configured", nil) + } + + params, err := extractRequestParams(request, logger) + if err != nil { + return nil, err + } + + if !strings.HasPrefix(params.noCodeModuleID, "nocode-") { + return nil, utils.LogAndReturnError(logger, "no_code_module_id must start with 'nocode-'", nil) + } + + project, noCodeModule, moduleMetadata, err := fetchModuleData(ctx, tfeClient, params.projectID, params.noCodeModuleID, logger) + if err != nil { + return nil, err + } + + elicitationProperties, requestedVars := buildElicitationSchema(moduleMetadata, noCodeModule) + + result, err := requestVariableValues(ctx, mcpServer, params.noCodeModuleID, elicitationProperties, requestedVars, logger) + if err != nil { + return nil, err + } + + variables, err := processElicitationResponse(result, requestedVars, elicitationProperties, logger) + if err != nil { + return nil, err + } + + workspace, err := tfeClient.RegistryNoCodeModules.CreateWorkspace(ctx, params.noCodeModuleID, &tfe.RegistryNoCodeModuleCreateWorkspaceOptions{ + Name: params.workspaceName, + Project: project, + Variables: variables, + AutoApply: ¶ms.autoApply, + }) + if err != nil { + return nil, utils.LogAndReturnError(logger, "creating No Code module workspace", err) + } + + logger.Infof("Created No Code module workspace: %s", workspace.ID) + buf, err := getWorkspaceDetailsForTools(ctx, "create_no_code_workspace", tfeClient, workspace, logger) + if err != nil { + return nil, utils.LogAndReturnError(logger, "getting workspace details for tools", err) + } + + return mcp.NewToolResultText(buf.String()), nil +} + +type workspaceParams struct { + noCodeModuleID string + workspaceName string + projectID string + autoApply bool +} + +func extractRequestParams(request mcp.CallToolRequest, logger *log.Logger) (*workspaceParams, error) { + noCodeModuleID, err := request.RequireString("no_code_module_id") + if err != nil { + return nil, utils.LogAndReturnError(logger, "the 'no_code_module_id' parameter is required", err) + } + + workspaceName, err := request.RequireString("workspace_name") + if err != nil { + return nil, utils.LogAndReturnError(logger, "the 'workspace_name' parameter is required", err) + } + + projectID, err := request.RequireString("project_id") + if err != nil { + return nil, utils.LogAndReturnError(logger, "the 'project_id' parameter is required", err) + } + + return &workspaceParams{ + noCodeModuleID: noCodeModuleID, + workspaceName: workspaceName, + projectID: projectID, + autoApply: request.GetBool("auto_apply", false), + }, nil +} + +func fetchModuleData(ctx context.Context, tfeClient *tfe.Client, projectID, noCodeModuleID string, logger *log.Logger) (*tfe.Project, *tfe.RegistryNoCodeModule, *client.ModuleMetadata, error) { + project, err := tfeClient.Projects.Read(ctx, projectID) + if err != nil { + return nil, nil, nil, utils.LogAndReturnError(logger, "reading project", err) + } + + noCodeModule, err := tfeClient.RegistryNoCodeModules.Read(ctx, noCodeModuleID, &tfe.RegistryNoCodeModuleReadOptions{ + Include: []tfe.RegistryNoCodeModuleIncludeOpt{tfe.RegistryNoCodeIncludeVariableOptions}, + }) + if err != nil { + return nil, nil, nil, utils.LogAndReturnError(logger, "reading No Code module", err) + } + + registryModule, err := tfeClient.RegistryModules.Read(ctx, tfe.RegistryModuleID{ID: noCodeModule.RegistryModule.ID}) + if err != nil { + return nil, nil, nil, utils.LogAndReturnError(logger, "reading Registry module", err) + } + + metadataPath := path.Join("/api/registry/private/v2/modules", registryModule.Namespace, registryModule.Name, registryModule.Provider, "metadata", noCodeModule.VersionPin) + metadataData, err := utils.MakeCustomGetRequestRaw(ctx, tfeClient, metadataPath, map[string][]string{"organization_name": {noCodeModule.Organization.Name}}) + if err != nil { + return nil, nil, nil, utils.LogAndReturnError(logger, "making module metadata API request", err) + } + + var moduleMetadata client.ModuleMetadata + if err := json.Unmarshal(metadataData, &moduleMetadata); err != nil { + return nil, nil, nil, utils.LogAndReturnError(logger, "unmarshalling module metadata", err) + } + + return project, noCodeModule, &moduleMetadata, nil +} + +func buildElicitationSchema(moduleMetadata *client.ModuleMetadata, noCodeModule *tfe.RegistryNoCodeModule) (map[string]any, []string) { + elicitationProperties := make(map[string]any) + requestedVars := make([]string, 0, len(moduleMetadata.Data.Attributes.InputVariables)) + + for _, inputVar := range moduleMetadata.Data.Attributes.InputVariables { + property := buildPropertySchema(inputVar, noCodeModule) + elicitationProperties[inputVar.Name] = property + requestedVars = append(requestedVars, inputVar.Name) + } + + return elicitationProperties, requestedVars +} + +func buildPropertySchema(inputVar struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` +}, noCodeModule *tfe.RegistryNoCodeModule) map[string]any { + property := map[string]any{ + "title": inputVar.Name, + "description": inputVar.Description, + "type": mapTerraformTypeToJSON(inputVar.Type), + } + + if enumOptions := findEnumOptions(inputVar.Name, inputVar.Type, noCodeModule.VariableOptions); enumOptions != nil { + property["enum"] = enumOptions + } + + return property +} + +func mapTerraformTypeToJSON(tfType string) string { + switch tfType { + case "string": + return "string" + case "number": + return "number" + case "bool": + return "boolean" + default: + return "string" + } +} + +func findEnumOptions(varName, varType string, variableOptions []*tfe.NoCodeVariableOption) any { + for _, varOpt := range variableOptions { + if varOpt.VariableName != varName || len(varOpt.Options) == 0 { + continue + } + + switch varType { + case "number": + return convertToFloatEnum(varOpt.Options) + case "bool": + return convertToBoolEnum(varOpt.Options) + default: + return varOpt.Options + } + } + return nil +} + +func convertToFloatEnum(options []string) []float64 { + result := make([]float64, 0, len(options)) + for _, opt := range options { + if floatVal, err := strconv.ParseFloat(opt, 64); err == nil { + result = append(result, floatVal) + } + } + if len(result) > 0 { + return result + } + return nil +} + +func convertToBoolEnum(options []string) []bool { + result := make([]bool, 0, len(options)) + for _, opt := range options { + if boolVal, err := strconv.ParseBool(opt); err == nil { + result = append(result, boolVal) + } + } + if len(result) > 0 { + return result + } + return nil +} + +func requestVariableValues(ctx context.Context, mcpServer *server.MCPServer, moduleID string, properties map[string]any, required []string, logger *log.Logger) (*mcp.ElicitationResult, error) { + request := mcp.ElicitationRequest{ + Params: mcp.ElicitationParams{ + Message: fmt.Sprintf("The No Code module '%s' requires %d variable(s) to create the workspace. Please provide values for the required variables.", moduleID, len(required)), + RequestedSchema: map[string]any{ + "type": "object", + "properties": properties, + "required": required, + }, + }, + } + + result, err := mcpServer.RequestElicitation(ctx, request) + if err != nil { + return nil, utils.LogAndReturnError(logger, "failed to request elicitation", err) + } + + return result, nil +} + +func processElicitationResponse(result *mcp.ElicitationResult, requestedVars []string, elicitationProperties map[string]any, logger *log.Logger) ([]*tfe.Variable, error) { + switch result.Action { + case mcp.ElicitationResponseActionDecline: + return nil, utils.LogAndReturnError(logger, "No Code module workspace creation declined by user", nil) + case mcp.ElicitationResponseActionCancel: + return nil, utils.LogAndReturnError(logger, "No Code module workspace creation cancelled by user", nil) + case mcp.ElicitationResponseActionAccept: + return extractVariablesFromResponse(result.Content, requestedVars, elicitationProperties, logger) + default: + return nil, utils.LogAndReturnError(logger, fmt.Sprintf("unexpected elicitation response action: %s", result.Action), nil) + } +} + +func extractVariablesFromResponse(content any, requestedVars []string, elicitationProperties map[string]any, logger *log.Logger) ([]*tfe.Variable, error) { + data, ok := content.(map[string]any) + if !ok { + return nil, utils.LogAndReturnError(logger, "elicitation response content is not a map", fmt.Errorf("expected map[string]any, got %T", content)) + } + + variables := make([]*tfe.Variable, 0, len(requestedVars)) + for _, varName := range requestedVars { + variable, err := createVariable(varName, data, elicitationProperties, logger) + if err != nil { + return nil, err + } + variables = append(variables, variable) + } + + return variables, nil +} + +func createVariable(varName string, data map[string]any, elicitationProperties map[string]any, logger *log.Logger) (*tfe.Variable, error) { + valueRaw, exists := data[varName] + if !exists { + return nil, utils.LogAndReturnError(logger, fmt.Sprintf("required variable '%s' is missing from elicitation response", varName), nil) + } + + propertyDef, ok := elicitationProperties[varName].(map[string]any) + if !ok { + return nil, utils.LogAndReturnError(logger, fmt.Sprintf("invalid property definition for variable '%s'", varName), nil) + } + + varType, _ := propertyDef["type"].(string) + if varType == "" { + varType = "string" + } + + value, err := convertVariableValue(varName, varType, valueRaw, logger) + if err != nil { + return nil, err + } + + return &tfe.Variable{ + Key: varName, + Value: value, + Category: tfe.CategoryTerraform, + }, nil +} + +func convertVariableValue(varName, varType string, valueRaw any, logger *log.Logger) (string, error) { + switch varType { + case "string": + strValue, ok := valueRaw.(string) + if !ok { + return "", utils.LogAndReturnError(logger, fmt.Sprintf("variable '%s' must be a string", varName), fmt.Errorf("got %T", valueRaw)) + } + if strValue == "" { + return "", utils.LogAndReturnError(logger, fmt.Sprintf("variable '%s' cannot be empty", varName), nil) + } + return strValue, nil + + case "number": + return convertNumberValue(varName, valueRaw, logger) + + case "boolean": + boolValue, ok := valueRaw.(bool) + if !ok { + return "", utils.LogAndReturnError(logger, fmt.Sprintf("variable '%s' must be a boolean", varName), fmt.Errorf("got %T", valueRaw)) + } + return fmt.Sprintf("%t", boolValue), nil + + default: + jsonValue, err := json.Marshal(valueRaw) + if err != nil { + return "", utils.LogAndReturnError(logger, fmt.Sprintf("failed to marshal variable '%s'", varName), err) + } + return string(jsonValue), nil + } +} + +func convertNumberValue(varName string, valueRaw any, logger *log.Logger) (string, error) { + switch v := valueRaw.(type) { + case float64: + return fmt.Sprintf("%v", v), nil + case int: + return fmt.Sprintf("%d", v), nil + case string: + return v, nil + default: + return "", utils.LogAndReturnError(logger, fmt.Sprintf("variable '%s' must be a number", varName), fmt.Errorf("got %T", valueRaw)) + } +} diff --git a/pkg/tools/tfe/create_no_code_workspace_test.go b/pkg/tools/tfe/create_no_code_workspace_test.go new file mode 100644 index 00000000..ecde161c --- /dev/null +++ b/pkg/tools/tfe/create_no_code_workspace_test.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "testing" + + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestCreateNoCodeWorkspace(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + // Create a mock MCP server for testing + mcpServer := &server.MCPServer{} + + t.Run("tool creation", func(t *testing.T) { + tool := CreateNoCodeWorkspace(logger, mcpServer) + + // Check that the tool is properly configured + assert.Equal(t, "create_no_code_workspace", tool.Tool.Name) + assert.Contains(t, tool.Tool.Description, "Creates a new Terraform No Code module workspace") + + // Check required parameters + assert.Contains(t, tool.Tool.InputSchema.Required, "no_code_module_id") + assert.Contains(t, tool.Tool.InputSchema.Required, "workspace_name") + + // Check that it accepts open world parameters (for dynamic variables) + // The tool should be configured to accept additional parameters beyond those defined + assert.NotNil(t, tool.Tool.InputSchema.Properties) + assert.Contains(t, tool.Tool.InputSchema.Properties, "no_code_module_id") + assert.Contains(t, tool.Tool.InputSchema.Properties, "workspace_name") + assert.Contains(t, tool.Tool.InputSchema.Properties, "auto_apply") + + // Verify the tool has elicitation capabilities through its configuration + // The WithOpenWorldHintAnnotation(true) allows for dynamic parameter acceptance + annotations := tool.Tool.Annotations + assert.NotNil(t, annotations) + assert.NotNil(t, annotations.OpenWorldHint) + assert.True(t, *annotations.OpenWorldHint) + + // Handler should not be nil + assert.NotNil(t, tool.Handler) + }) +} diff --git a/pkg/tools/tfe/create_run.go b/pkg/tools/tfe/create_run.go index b8def14a..032dc9ae 100644 --- a/pkg/tools/tfe/create_run.go +++ b/pkg/tools/tfe/create_run.go @@ -40,6 +40,7 @@ func CreateRunSafe(logger *log.Logger) server.ServerTool { ), mcp.WithString("message", mcp.Description("Optional message for the run"), + mcp.DefaultString("Triggered via Terraform MCP Server"), ), ), Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { diff --git a/pkg/tools/tfe/list_workspaces_test.go b/pkg/tools/tfe/list_workspaces_test.go index ed5bf993..feeb75aa 100644 --- a/pkg/tools/tfe/list_workspaces_test.go +++ b/pkg/tools/tfe/list_workspaces_test.go @@ -95,8 +95,7 @@ func TestSearchWorkspaces(t *testing.T) { } mockWorkspaceList := &tfe.WorkspaceList{ - Items: mockWorkspaces, - Pagination: &tfe.Pagination{CurrentPage: 1, TotalCount: 2}, + Items: mockWorkspaces, } // Verify the mock workspace list structure diff --git a/pkg/tools/tfe/search_private_modules.go b/pkg/tools/tfe/search_private_modules.go index 1babaf56..3247e3f5 100644 --- a/pkg/tools/tfe/search_private_modules.go +++ b/pkg/tools/tfe/search_private_modules.go @@ -139,6 +139,12 @@ func searchPrivateModulesHandler(ctx context.Context, request mcp.CallToolReques builder.WriteString(fmt.Sprintf(" Provider: %s\n", module.Provider)) builder.WriteString(fmt.Sprintf(" No Code Module: %t\n", module.NoCode)) + if module.NoCode { + for _, noCodeModule := range module.RegistryNoCodeModule { + builder.WriteString(fmt.Sprintf(" - no_code_module_id: %s\n", noCodeModule.ID)) + } + } + builder.WriteString("\n") } 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) + } + }) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 18a8a9dd..a0746baa 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,12 +4,15 @@ package utils import ( + "context" "fmt" + "io" "os" "regexp" "slices" "strings" + "github.com/hashicorp/go-tfe" log "github.com/sirupsen/logrus" ) @@ -62,7 +65,7 @@ func IsValidProviderVersionFormat(version string) bool { } func IsValidProviderDocumentType(providerDocumentType string) bool { - validTypes := []string{"resources", "data-sources", "functions", "guides", "overview"} + validTypes := []string{"resources", "data-sources", "functions", "guides", "overview", "actions", "list-resources"} return slices.Contains(validTypes, providerDocumentType) } @@ -76,7 +79,7 @@ func LogAndReturnError(logger *log.Logger, context string, err error) error { } func IsV2ProviderDocumentType(dataType string) bool { - v2Categories := []string{"guides", "functions", "overview", "actions"} + v2Categories := []string{"guides", "functions", "overview", "actions", "list-resources"} return slices.Contains(v2Categories, dataType) } @@ -111,3 +114,24 @@ func GetEnv(key, fallback string) string { } return fallback } + +// This function is used for custom GET requests using the TFE client. +func MakeCustomGetRequestRaw(ctx context.Context, client *tfe.Client, path string, additionalQueryParams map[string][]string) ([]byte, error) { + req, err := client.NewRequestWithAdditionalQueryParams("GET", path, nil, additionalQueryParams) + if err != nil { + return nil, err + } + + respBody, err := req.DoRaw(ctx) + if err != nil { + return nil, err + } + defer respBody.Close() + + body, err := io.ReadAll(respBody) + if err != nil { + return nil, err + } + + return body, nil +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index e6863583..dbfcf9d2 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -70,7 +70,7 @@ func TestIsValidProviderVersionFormat(t *testing.T) { } func TestIsValidProviderDataType(t *testing.T) { - valid := []string{"resources", "data-sources", "functions", "guides", "overview"} + valid := []string{"resources", "data-sources", "functions", "guides", "overview", "actions", "list-resources"} invalid := []string{"foo", "bar", ""} for _, v := range valid { if !IsValidProviderDocumentType(v) { @@ -92,7 +92,7 @@ func TestLogAndReturnError_NilLogger(t *testing.T) { } func TestIsV2ProviderDataType(t *testing.T) { - valid := []string{"guides", "functions", "overview", "actions"} + valid := []string{"guides", "functions", "overview", "actions", "list-resources"} invalid := []string{"resources", "data-sources", "foo"} for _, v := range valid { if !IsV2ProviderDocumentType(v) { diff --git a/scripts/compare-versions.sh b/scripts/compare-versions.sh new file mode 100755 index 00000000..0aaf6f6a --- /dev/null +++ b/scripts/compare-versions.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Script to compare version numbers using version/VERSION as source of truth +set -euo pipefail +echo "Checking version consistency using version/VERSION as source of truth..." + +# Read version from version/VERSION file (source of truth) +if [ -f "version/VERSION" ]; then + SOURCE_VERSION=$(tr -d '\n\r\t ' < version/VERSION) + echo "" + echo "Source version (version/VERSION): '$SOURCE_VERSION'" +else + echo "" + echo "Error: version/VERSION file not found" + exit 1 +fi + +# Configurable list of JSON files to check +JSON_FILES=("gemini-extension.json" "server.json") + +VERSION_MISMATCH=false + +# Check version field in each JSON file +for json_file in "${JSON_FILES[@]}"; do + if [ -f "$json_file" ]; then + JSON_VERSION=$(jq -r '.version' "$json_file" | tr -d '\n\r\t ') + echo "Version in $json_file: '$JSON_VERSION'" + echo "" + + if [ "$SOURCE_VERSION" != "$JSON_VERSION" ]; then + echo "❌ Version mismatch: $json_file ($JSON_VERSION) should match version/VERSION ($SOURCE_VERSION)" + VERSION_MISMATCH=true + else + echo "✅ $json_file version matches" + fi + else + echo "Warning: $json_file file not found" + fi +done + +# Check terraform-mcp-server: occurrences in JSON files only +echo "Checking terraform-mcp-server: occurrences in JSON files..." +echo "" +DOCKER_PATTERN="terraform-mcp-server:" + +for json_file in "${JSON_FILES[@]}"; do + if [ -f "$json_file" ]; then + FOUND_DOCKER_VERSIONS=$(grep -o "${DOCKER_PATTERN}[^\"[:space:]]*" "$json_file" 2>/dev/null | sort -u || true) + + if [ -n "$FOUND_DOCKER_VERSIONS" ]; then + echo "Found terraform-mcp-server references in $json_file:" + echo "$FOUND_DOCKER_VERSIONS" + echo "" + + # Check each found version + while IFS= read -r docker_ref; do + if [ -n "$docker_ref" ]; then + DOCKER_VERSION=$(echo "$docker_ref" | sed "s/${DOCKER_PATTERN}//") + # Skip if it's just the pattern without a version, or if it contains variables/placeholders + if [ -n "$DOCKER_VERSION" ] && [[ ! "$DOCKER_VERSION" =~ [\$\{] ]] && [ "$DOCKER_VERSION" != "latest" ]; then + echo "Found Docker reference version in $json_file: '$DOCKER_VERSION'" + if [ "$SOURCE_VERSION" != "$DOCKER_VERSION" ]; then + echo "❌ Version mismatch in $json_file: terraform-mcp-server:$DOCKER_VERSION should be terraform-mcp-server:$SOURCE_VERSION" + VERSION_MISMATCH=true + fi + fi + fi + done <<< "$FOUND_DOCKER_VERSIONS" + fi + fi +done + +if [ "$VERSION_MISMATCH" = true ]; then + echo "" + echo "Please run scripts/update-json-version.sh before merging into release" + exit 1 +else + echo "" + echo "✅ All files match the source version: $SOURCE_VERSION" +fi + +echo "Version comparison completed successfully." diff --git a/scripts/update-json-version.sh b/scripts/update-json-version.sh new file mode 100755 index 00000000..2dc55286 --- /dev/null +++ b/scripts/update-json-version.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Script to update version references in JSON files +# Usage: ./update-json-version.sh [version-file-path] +# +# This script updates: +# 1. "version": "" fields +# 2. terraform-mcp-server: references +# +# Arguments: +# json-file-path: Path to the JSON file to update +# version-file-path: Path to the VERSION file (default: version/VERSION) + +JSON_FILE="${1:-}" +VERSION_FILE="${2:-version/VERSION}" + +# Function to display usage +usage() { + echo "Usage: $0 [version-file-path]" + echo "" + echo "Updates version references in JSON files based on VERSION file content." + echo "" + echo "Arguments:" + echo " json-file-path Path to the JSON file to update (required)" + echo " version-file-path Path to the VERSION file (default: version/VERSION)" + echo "" + echo "Examples:" + echo " $0 server.json" + echo " $0 gemini-extension.json version/VERSION" + echo "" + exit 1 +} + +# Check if JSON file argument is provided +if [[ -z "$JSON_FILE" ]]; then + echo "Error: JSON file path is required" + echo "" + usage +fi + +# Check if JSON file exists +if [[ ! -f "$JSON_FILE" ]]; then + echo "Error: JSON file '$JSON_FILE' does not exist" + exit 1 +fi + +# Check if VERSION file exists +if [[ ! -f "$VERSION_FILE" ]]; then + echo "Error: VERSION file '$VERSION_FILE' does not exist" + exit 1 +fi + +# Read the version from the VERSION file and trim whitespace +NEW_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE") + +if [[ -z "$NEW_VERSION" ]]; then + echo "Error: VERSION file '$VERSION_FILE' is empty" + exit 1 +fi + +echo "Updating JSON file: $JSON_FILE" +echo "Using version: $NEW_VERSION" +echo "Version source: $VERSION_FILE" + +# Create a backup of the original file +BACKUP_FILE="${JSON_FILE}.backup.$(date +%Y%m%d_%H%M%S)" +cp "$JSON_FILE" "$BACKUP_FILE" +echo "Created backup: $BACKUP_FILE" + +# Use sed to update version references +# Patterns updated: +# 1. "version": "" -> "version": "" +# 2. hashicorp/terraform-mcp-server: -> hashicorp/terraform-mcp-server: +# 3. docker.io/hashicorp/terraform-mcp-server: -> docker.io/hashicorp/terraform-mcp-server: + +# For macOS compatibility, we'll use a temporary file approach +TEMP_FILE=$(mktemp) + +# Update version field and terraform-mcp-server references +sed -E \ + -e 's/"version": *"[^"]*"/"version": "'"$NEW_VERSION"'"/g' \ + -e 's/(^|[^a-zA-Z0-9.-])terraform-mcp-server:[^"[:space:]]*/\1terraform-mcp-server:'"$NEW_VERSION"'/g' \ + -e 's/(docker\.io\/)?hashicorp\/terraform-mcp-server:[^"[:space:]]*/\1hashicorp\/terraform-mcp-server:'"$NEW_VERSION"'/g' \ + "$JSON_FILE" > "$TEMP_FILE" + +# Move the temporary file back to the original +mv "$TEMP_FILE" "$JSON_FILE" + +echo "Successfully updated version references in $JSON_FILE" +echo "Changed version references to: $NEW_VERSION" + +# Show what was changed (optional verification) +echo "" +echo "Updated references found:" +grep -E '"version": *"[^"]*"|(docker\.io\/)?hashicorp\/terraform-mcp-server:[^"[:space:]]*' "$JSON_FILE" | sed 's/^/ /' diff --git a/server.json b/server.json new file mode 100644 index 00000000..5bda0707 --- /dev/null +++ b/server.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.hashicorp/terraform-mcp-server", + "title": "Terraform", + "description": "Generate more accurate Terraform and automate workflows for HCP Terraform and Terraform Enterprise", + "repository": { + "url": "https://github.com/hashicorp/terraform-mcp-server", + "source": "github" + }, + "version": "0.3.3", + "packages": [ + { + "registryType": "oci", + "identifier": "docker.io/hashicorp/terraform-mcp-server:0.3.3", + "transport": { + "type": "stdio" + }, + "runtimeHint": "docker", + "runtimeArguments": [ + { + "type": "positional", + "value": "run" + }, + { + "type": "named", + "name": "--rm" + }, + { + "type": "named", + "name": "-i" + }, + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "isRepeated": true + }, + { + "type": "positional", + "valueHint": "env_var_name", + "value": "TFE_ADDRESS", + "description": "Environment variable name" + }, + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "isRepeated": true + }, + { + "type": "positional", + "valueHint": "env_var_name", + "value": "TFE_TOKEN", + "description": "Environment variable name" + }, + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "isRepeated": true + }, + { + "type": "positional", + "valueHint": "env_var_name", + "value": "ENABLE_TF_OPERATIONS", + "description": "Environment variable name" + }, + { + "type": "positional", + "valueHint": "image_name", + "value": "hashicorp/terraform-mcp-server:0.3.3", + "description": "The container image to run" + } + ], + "environmentVariables": [ + { + "name": "TFE_ADDRESS", + "description": "HCP Terraform or Terraform Enterprise base URL.", + "default": "https://app.terraform.io/", + "isRequired": false + }, + { + "name": "TFE_TOKEN", + "description": "HCP Terraform or Terraform Enterprise API token used to authenticate requests.", + "isRequired": false, + "isSecret": true + }, + { + "name": "ENABLE_TF_OPERATIONS", + "description": "Set to true to enable tools that execute Terraform operations requiring explicit approval.", + "default": "false", + "format": "boolean", + "choices": [ + "true", + "false" + ], + "isRequired": false + } + ] + } + ] +} diff --git a/version/VERSION b/version/VERSION index 1d0ba9ea..1c09c74e 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -0.4.0 +0.3.3