From b565683187f3724377637fdb09541c4160288307 Mon Sep 17 00:00:00 2001 From: Ankit Agarwal Date: Fri, 12 Dec 2025 11:27:41 +0530 Subject: [PATCH] Add get_run_logs tool to retrieve Terraform run logs This commit adds a new MCP tool 'get_run_logs' that enables users to fetch plan and apply logs from Terraform Cloud/Enterprise runs. Features: - Retrieve plan logs, apply logs, or both from a run - Optional metadata including run status, timestamps, and version info - Proper error handling for runs without available logs - Read-only, non-destructive operation Changes: - Added pkg/tools/tfe/get_run_logs.go with main implementation - Added pkg/tools/tfe/get_run_logs_test.go with unit tests - Registered new tool in pkg/tools/dynamic_tool.go - Updated CHANGELOG.md with new feature documentation The tool uses the go-tfe library's Plans.Logs() and Applies.Logs() methods to fetch log data and returns it in a structured JSON format. --- CHANGELOG.md | 1 + pkg/tools/dynamic_tool.go | 5 + pkg/tools/tfe/get_run_logs.go | 157 +++++++++++++++++++++++++++++ pkg/tools/tfe/get_run_logs_test.go | 44 ++++++++ 4 files changed, 207 insertions(+) create mode 100644 pkg/tools/tfe/get_run_logs.go create mode 100644 pkg/tools/tfe/get_run_logs_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2fac1a..504690fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/pkg/tools/dynamic_tool.go b/pkg/tools/dynamic_tool.go index 795339b8..73ba5775 100644 --- a/pkg/tools/dynamic_tool.go +++ b/pkg/tools/dynamic_tool.go @@ -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) diff --git a/pkg/tools/tfe/get_run_logs.go b/pkg/tools/tfe/get_run_logs.go new file mode 100644 index 00000000..1e5d16a9 --- /dev/null +++ b/pkg/tools/tfe/get_run_logs.go @@ -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 +} + diff --git a/pkg/tools/tfe/get_run_logs_test.go b/pkg/tools/tfe/get_run_logs_test.go new file mode 100644 index 00000000..8a15fcce --- /dev/null +++ b/pkg/tools/tfe/get_run_logs_test.go @@ -0,0 +1,44 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tools + +import ( + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func 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) + }) +} +