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 @@ -3,6 +3,7 @@
FEATURES

* **Toolsets Flag**: Added `--toolsets` flag to selectively enable tool groups. Three toolset groups are available: `registry` (public Terraform Registry), `registry-private` (private TFE/TFC registry), and `terraform` (TFE/TFC operations). Default is `registry` only.
* [New Tool] `get_run_logs` Added capability to retrieve plan and apply logs from Terraform runs. Users can fetch plan logs, apply logs, or both, with optional metadata about the run status.

## 0.3.3 (Nov 21, 2025)

Expand Down
5 changes: 5 additions & 0 deletions pkg/tools/dynamic_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ func (r *DynamicToolRegistry) registerTFETools() {
r.mcpServer.AddTool(tool.Tool, tool.Handler)
}

if toolsets.IsToolEnabled("get_run_logs", r.enabledToolsets) {
tool := r.createDynamicTFETool("get_run_logs", tfeTools.GetRunLogs)
r.mcpServer.AddTool(tool.Tool, tool.Handler)
}

// Terraform toolset - Variable set tools
if toolsets.IsToolEnabled("list_variable_sets", r.enabledToolsets) {
tool := r.createDynamicTFETool("list_variable_sets", tfeTools.ListVariableSets)
Expand Down
157 changes: 157 additions & 0 deletions pkg/tools/tfe/get_run_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"context"
"encoding/json"
"fmt"
"io"

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

// GetRunLogs creates a tool to fetch logs from a Terraform run (plan and/or apply logs).
func GetRunLogs(logger *log.Logger) server.ServerTool {
return server.ServerTool{
Tool: mcp.NewTool("get_run_logs",
mcp.WithDescription(`Fetches logs from a Terraform run. You can retrieve plan logs, apply logs, or both.`),
mcp.WithTitleAnnotation("Get logs from a Terraform run"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithString("run_id",
mcp.Required(),
mcp.Description("The ID of the run to get logs for"),
),
mcp.WithString("log_type",
mcp.Description("Type of logs to retrieve: 'plan', 'apply', or 'both'"),
mcp.Enum("plan", "apply", "both"),
mcp.DefaultString("both"),
),
mcp.WithBoolean("include_metadata",
mcp.Description("Include run metadata along with logs"),
mcp.DefaultBool(true),
),
),
Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return getRunLogsHandler(ctx, req, logger)
},
}
}

func getRunLogsHandler(ctx context.Context, request mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) {
runID, err := request.RequireString("run_id")
if err != nil {
return nil, utils.LogAndReturnError(logger, "The 'run_id' parameter is required", err)
}

logType := request.GetString("log_type", "both")
includeMetadata := request.GetBool("include_metadata", true)

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

// First, fetch the run details to get Plan and Apply IDs
run, err := tfeClient.Runs.Read(ctx, runID)
if err != nil {
return nil, utils.LogAndReturnError(logger, "reading run details", err)
}

result := make(map[string]interface{})

// Add metadata if requested
if includeMetadata {
result["run_id"] = run.ID
result["status"] = string(run.Status)
result["message"] = run.Message
result["created_at"] = run.CreatedAt
result["terraform_version"] = run.TerraformVersion
result["has_changes"] = run.HasChanges
result["is_destroy"] = run.IsDestroy
}

// Fetch plan logs if requested
if (logType == "plan" || logType == "both") && run.Plan != nil {
planLogs, err := fetchLogs(ctx, tfeClient, "plan", run.Plan.ID, logger)
if err != nil {
result["plan_logs_error"] = err.Error()
logger.WithError(err).Warn("Failed to fetch plan logs")
} else {
result["plan_logs"] = planLogs
if includeMetadata {
result["plan_id"] = run.Plan.ID
result["plan_status"] = string(run.Plan.Status)
}
}
} else if logType == "plan" && run.Plan == nil {
result["plan_logs"] = "Plan not yet available for this run"
}

// Fetch apply logs if requested
if (logType == "apply" || logType == "both") && run.Apply != nil {
applyLogs, err := fetchLogs(ctx, tfeClient, "apply", run.Apply.ID, logger)
if err != nil {
result["apply_logs_error"] = err.Error()
logger.WithError(err).Warn("Failed to fetch apply logs")
} else {
result["apply_logs"] = applyLogs
if includeMetadata {
result["apply_id"] = run.Apply.ID
result["apply_status"] = string(run.Apply.Status)
}
}
} else if logType == "apply" && run.Apply == nil {
result["apply_logs"] = "Apply not yet available for this run (may not have been applied yet)"
}

// Check if we got any logs
if result["plan_logs"] == nil && result["apply_logs"] == nil {
if logType == "both" {
result["message"] = "No logs available yet. The run may still be queued or in progress."
}
}

resultJSON, err := json.MarshalIndent(result, "", " ")
if err != nil {
return nil, utils.LogAndReturnError(logger, "marshalling run logs result", err)
}

return mcp.NewToolResultText(string(resultJSON)), nil
}

// fetchLogs is a helper function to fetch logs from either Plans or Applies
func fetchLogs(ctx context.Context, tfeClient *tfe.Client, logType string, id string, logger *log.Logger) (string, error) {
var logReader io.Reader
var err error

// Fetch logs based on type
switch logType {
case "plan":
logReader, err = tfeClient.Plans.Logs(ctx, id)
case "apply":
logReader, err = tfeClient.Applies.Logs(ctx, id)
default:
return "", fmt.Errorf("invalid log type: %s", logType)
}

if err != nil {
return "", fmt.Errorf("fetching %s logs: %w", logType, err)
}

// Read all logs from the reader
logBytes, err := io.ReadAll(logReader)
if err != nil {
return "", fmt.Errorf("reading %s logs: %w", logType, err)
}

return string(logBytes), nil
}

44 changes: 44 additions & 0 deletions pkg/tools/tfe/get_run_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tools

import (
"testing"

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

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

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

assert.Equal(t, "get_run_logs", tool.Tool.Name)
assert.Contains(t, tool.Tool.Description, "Fetches logs from a Terraform run")
assert.NotNil(t, tool.Handler)

// Check that read-only hint is true
assert.NotNil(t, tool.Tool.Annotations.ReadOnlyHint)
assert.True(t, *tool.Tool.Annotations.ReadOnlyHint)

// Check that destructive hint is false
assert.NotNil(t, tool.Tool.Annotations.DestructiveHint)
assert.False(t, *tool.Tool.Annotations.DestructiveHint)

// Check required parameters
assert.Contains(t, tool.Tool.InputSchema.Required, "run_id")

// Check that log_type property exists
logTypeProperty := tool.Tool.InputSchema.Properties["log_type"]
assert.NotNil(t, logTypeProperty)

// Check that include_metadata property exists
includeMetadataProperty := tool.Tool.InputSchema.Properties["include_metadata"]
assert.NotNil(t, includeMetadataProperty)
})
}