Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ IMPROVEMENTS
* Add `--heartbeat-interval` CLI flag and `MCP_HEARTBEAT_INTERVAL` env var for HTTP heartbeat in load-balanced environments
* Set custom User-Agent header for TFE API requests to enable tracking MCP server usage separately from other go-tfe clients [268](https://github.com/hashicorp/terraform-mcp-server/pull/268)
* Adding a new cli flags `--log-level` to set the desired log level for the server logs and `--log-format` for the logs formatting [286](https://github.com/hashicorp/terraform-mcp-server/pull/286)
* Add support for reading TFE tokens from credentials.tfrc.json with warning logs for failure modes [#265](https://github.com/hashicorp/terraform-mcp-server/issues/228)

FIXES

Expand Down
20 changes: 20 additions & 0 deletions pkg/client/config_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

//go:build !windows
// +build !windows

package client

import (
"os"
"path/filepath"
)

func configDir() (string, error) {
dir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(dir, ".terraform.d"), nil
}
26 changes: 26 additions & 0 deletions pkg/client/config_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

//go:build windows
// +build windows

package client

import (
"os"
"path/filepath"
)

func configDir() (string, error) {
// On Windows, Terraform uses %APPDATA%/terraform.d (no leading dot)
appData := os.Getenv("APPDATA")
if appData == "" {
// Fallback to UserHomeDir if APPDATA not set
dir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "terraform.d"), nil
}
return filepath.Join(appData, "terraform.d"), nil
}
78 changes: 78 additions & 0 deletions pkg/client/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package client

import (
"encoding/json"
"net/url"
"os"
"path/filepath"

log "github.com/sirupsen/logrus"
)

// credentialsFile represents the structure of credentials.tfrc.json
type credentialsFile struct {
Credentials map[string]credentialEntry `json:"credentials"`
}

type credentialEntry struct {
Token string `json:"token"`
}

// ReadCredentialsFile reads the Terraform CLI credentials file and returns
// the token for the specified hostname. Returns empty string if not found.
func ReadCredentialsFile(hostname string, logger *log.Logger) string {
if hostname == "" {
return ""
}

dir, err := configDir()
if err != nil {
logger.Warnf("Failed to get config directory for credentials file lookup: %v", err)
return ""
}

credPath := filepath.Join(dir, "credentials.tfrc.json")

data, err := os.ReadFile(credPath)
if err != nil {
if os.IsNotExist(err) {
logger.Debugf("No credentials file found at %s", credPath)
} else if os.IsPermission(err) {
logger.Warnf("Permission denied reading credentials file at %s", credPath)
} else {
logger.Warnf("Failed to read credentials file at %s: %v", credPath, err)
}
return ""
}

var creds credentialsFile
if err := json.Unmarshal(data, &creds); err != nil {
logger.Warnf("Failed to parse credentials file at %s: %v", credPath, err)
return ""
}

if entry, ok := creds.Credentials[hostname]; ok {
return entry.Token
}

logger.Debugf("No credentials found for hostname %q in credentials file", hostname)
return ""
}

// extractHostname extracts the hostname from a Terraform address URL.
// e.g., "https://app.terraform.io" -> "app.terraform.io"
func extractHostname(address string) string {
if address == "" {
return ""
}

parsed, err := url.Parse(address)
if err != nil {
return ""
}

return parsed.Hostname()
}
108 changes: 108 additions & 0 deletions pkg/client/credentials_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright IBM Corp. 2025
// SPDX-License-Identifier: MPL-2.0

package client

import (
"os"
"path/filepath"
"testing"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)

func TestExtractHostname(t *testing.T) {
tests := []struct {
name string
address string
expected string
}{
{"empty string", "", ""},
{"invalid url", "not-a-url", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractHostname(tt.address)
require.Equal(t, tt.expected, result)
})
}
}

func TestReadCredentialsFile(t *testing.T) {
logger := logrus.New()
logger.SetOutput(os.Stderr)

t.Run("empty hostname", func(t *testing.T) {
result := ReadCredentialsFile("", logger)
require.Empty(t, result)
})

t.Run("file not found", func(t *testing.T) {
result := ReadCredentialsFile("nonexistent.example.com", logger)
require.Empty(t, result)
})

t.Run("valid credentials file", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "terraform-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

terraformDir := filepath.Join(tmpDir, ".terraform.d")
err = os.MkdirAll(terraformDir, 0755)
require.NoError(t, err)

credContent := `{
"credentials": {
"app.terraform.io": {
"token": "test-token-123"
},
"tfe.example.com": {
"token": "enterprise-token-456"
}
}
}`
credPath := filepath.Join(terraformDir, "credentials.tfrc.json")
err = os.WriteFile(credPath, []byte(credContent), 0600)
require.NoError(t, err)

// Override HOME for this test
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)

// Test finding a token
token := ReadCredentialsFile("app.terraform.io", logger)
require.Equal(t, "test-token-123", token)

// Test finding another token
token = ReadCredentialsFile("tfe.example.com", logger)
require.Equal(t, "enterprise-token-456", token)

// Test hostname not in file
token = ReadCredentialsFile("other.terraform.io", logger)
require.Empty(t, token)
})

t.Run("malformed json", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "terraform-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

terraformDir := filepath.Join(tmpDir, ".terraform.d")
err = os.MkdirAll(terraformDir, 0755)
require.NoError(t, err)

credPath := filepath.Join(terraformDir, "credentials.tfrc.json")
err = os.WriteFile(credPath, []byte("not valid json"), 0600)
require.NoError(t, err)

originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)

token := ReadCredentialsFile("app.terraform.io", logger)
require.Empty(t, token)
})
}
3 changes: 3 additions & 0 deletions pkg/client/tfe_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ func CreateTfeClientForSession(ctx context.Context, session server.ClientSession
if !ok || terraformToken == "" {
terraformToken = utils.GetEnv(TerraformToken, "")
}
if terraformToken == "" {
terraformToken = ReadCredentialsFile(extractHostname(terraformAddress), logger)
}

client, err := NewTfeClient(session.SessionID(), terraformAddress, parseTerraformSkipTLSVerify(ctx), terraformToken, logger)
return client, err
Expand Down
Loading