From 1762b77205327d2c728ea318a8f11d903d742d8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:30:57 +0000 Subject: [PATCH 1/3] Initial plan From 8bea5e3dc78c95528c14367ac446bde0ec164d7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:41:50 +0000 Subject: [PATCH 2/3] Add Readme section to get_module_details output and remove redundant sections Agent-Logs-Url: https://github.com/huynhsontung/terraform-mcp-server/sessions/15dc6c8a-0ca1-4827-b7f6-550a6b2e082d Co-authored-by: huynhsontung <31434093+huynhsontung@users.noreply.github.com> --- pkg/tools/registry/get_module_details.go | 9 ++ pkg/tools/registry/get_module_details_test.go | 93 +++++++++++++++++++ pkg/tools/tfe/get_private_module_details.go | 35 +------ pkg/utils/utils.go | 33 +++++++ pkg/utils/utils_test.go | 62 +++++++++++++ 5 files changed, 199 insertions(+), 33 deletions(-) diff --git a/pkg/tools/registry/get_module_details.go b/pkg/tools/registry/get_module_details.go index c43d40e6..ab32b174 100644 --- a/pkg/tools/registry/get_module_details.go +++ b/pkg/tools/registry/get_module_details.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/hashicorp/terraform-mcp-server/pkg/client" + "github.com/hashicorp/terraform-mcp-server/pkg/utils" log "github.com/sirupsen/logrus" "github.com/mark3labs/mcp-go/mcp" @@ -165,6 +166,14 @@ func unmarshalTerraformModule(response []byte) (string, error) { builder.WriteString("\n") } + // Format Root Readme + if terraformModules.Root.Readme != "" { + cleanedReadme := utils.RemoveReadmeSections(terraformModules.Root.Readme) + builder.WriteString("### Readme\n\n") + builder.WriteString(cleanedReadme) + builder.WriteString("\n\n") + } + content := builder.String() return content, nil } diff --git a/pkg/tools/registry/get_module_details_test.go b/pkg/tools/registry/get_module_details_test.go index fabf046b..3824a7d3 100644 --- a/pkg/tools/registry/get_module_details_test.go +++ b/pkg/tools/registry/get_module_details_test.go @@ -113,6 +113,99 @@ func TestUnmarshalModuleSingular_InvalidJSON(t *testing.T) { } } +func TestUnmarshalModuleSingular_WithReadme(t *testing.T) { + resp := []byte(`{ + "id": "namespace/name/provider/1.0.0", + "owner": "owner", + "namespace": "namespace", + "name": "name", + "version": "1.0.0", + "provider": "provider", + "provider_logo_url": "", + "description": "A test module", + "source": "source", + "tag": "", + "published_at": "2023-01-01T00:00:00Z", + "downloads": 1, + "verified": true, + "root": { + "path": "", + "name": "root", + "readme": "# Module Title\n\nSome description.\n\n## Inputs\n\n| Name | Type |\n\n## Usage\n\nUsage info here.", + "empty": false, + "inputs": [], + "outputs": [], + "dependencies": [], + "provider_dependencies": [], + "resources": [] + }, + "submodules": [], + "examples": [], + "providers": ["provider"], + "versions": ["1.0.0"], + "deprecation": null + }`) + out, err := unmarshalTerraformModule(resp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !strings.Contains(out, "### Readme") { + t.Errorf("expected output to contain '### Readme' section, got %q", out) + } + if !strings.Contains(out, "Module Title") { + t.Errorf("expected output to contain readme content, got %q", out) + } + // The Inputs section from the README should be removed by RemoveReadmeSections + if strings.Contains(out, "## Inputs") { + t.Errorf("expected '## Inputs' section to be removed from readme, got %q", out) + } + // Other readme content should be preserved + if !strings.Contains(out, "## Usage") { + t.Errorf("expected '## Usage' section to be preserved in readme, got %q", out) + } +} + +func TestUnmarshalModuleSingular_EmptyReadme(t *testing.T) { + resp := []byte(`{ + "id": "namespace/name/provider/1.0.0", + "owner": "owner", + "namespace": "namespace", + "name": "name", + "version": "1.0.0", + "provider": "provider", + "provider_logo_url": "", + "description": "A test module", + "source": "source", + "tag": "", + "published_at": "2023-01-01T00:00:00Z", + "downloads": 1, + "verified": true, + "root": { + "path": "", + "name": "root", + "readme": "", + "empty": false, + "inputs": [], + "outputs": [], + "dependencies": [], + "provider_dependencies": [], + "resources": [] + }, + "submodules": [], + "examples": [], + "providers": ["provider"], + "versions": ["1.0.0"], + "deprecation": null + }`) + out, err := unmarshalTerraformModule(resp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if strings.Contains(out, "### Readme") { + t.Errorf("expected output NOT to contain '### Readme' when readme is empty, got %q", out) + } +} + // --- ValidateModuleID --- func TestValidateModuleID_ValidFormat(t *testing.T) { validIDs := []string{ diff --git a/pkg/tools/tfe/get_private_module_details.go b/pkg/tools/tfe/get_private_module_details.go index 03bd5759..779119ce 100644 --- a/pkg/tools/tfe/get_private_module_details.go +++ b/pkg/tools/tfe/get_private_module_details.go @@ -7,11 +7,11 @@ import ( "context" "fmt" "path" - "regexp" "strings" "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform-mcp-server/pkg/client" + "github.com/hashicorp/terraform-mcp-server/pkg/utils" log "github.com/sirupsen/logrus" "github.com/mark3labs/mcp-go/mcp" @@ -242,7 +242,7 @@ func buildPrivateModuleDetailsResponse(registryModule *tfe.RegistryModule, } if terraformRegistryModule != nil && terraformRegistryModule.Root.Readme != "" { - cleanedReadme := removeReadmeSections(terraformRegistryModule.Root.Readme) + cleanedReadme := utils.RemoveReadmeSections(terraformRegistryModule.Root.Readme) builder.WriteString("README:\n") builder.WriteString(strings.Repeat("-", 20) + "\n") builder.WriteString(cleanedReadme) @@ -259,34 +259,3 @@ func buildPrivateModuleDetailsResponse(registryModule *tfe.RegistryModule, return mcp.NewToolResultText(builder.String()) } - -func removeReadmeSections(readme string) string { - lines := strings.Split(readme, "\n") - var result []string - skipSection := false - - for _, line := range lines { - lowerLine := strings.ToLower(strings.TrimSpace(line)) - if strings.HasPrefix(lowerLine, "##") || strings.HasPrefix(lowerLine, "###") || strings.HasPrefix(lowerLine, "####") { - if strings.Contains(lowerLine, "inputs") || - strings.Contains(lowerLine, "outputs") || - strings.Contains(lowerLine, "dependencies") || - strings.Contains(lowerLine, "provider dependencies") || - strings.Contains(lowerLine, "resources") { - skipSection = true - continue - } else { - skipSection = false - } - } - - if !skipSection { - result = append(result, line) - } - } - - cleaned := strings.Join(result, "\n") - cleaned = regexp.MustCompile(`\n{3,}`).ReplaceAllString(cleaned, "\n\n") - - return strings.TrimSpace(cleaned) -} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 7b0e7b3a..02e09358 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -107,6 +107,39 @@ func ExtractReadme(readme string) string { return strings.TrimSuffix(builder.String(), "\n") } +// RemoveReadmeSections removes sections from a README that duplicate information +// already surfaced in structured fields (inputs, outputs, dependencies, resources). +func RemoveReadmeSections(readme string) string { + lines := strings.Split(readme, "\n") + var result []string + skipSection := false + + for _, line := range lines { + lowerLine := strings.ToLower(strings.TrimSpace(line)) + if strings.HasPrefix(lowerLine, "##") || strings.HasPrefix(lowerLine, "###") || strings.HasPrefix(lowerLine, "####") { + if strings.Contains(lowerLine, "inputs") || + strings.Contains(lowerLine, "outputs") || + strings.Contains(lowerLine, "dependencies") || + strings.Contains(lowerLine, "provider dependencies") || + strings.Contains(lowerLine, "resources") { + skipSection = true + continue + } else { + skipSection = false + } + } + + if !skipSection { + result = append(result, line) + } + } + + cleaned := strings.Join(result, "\n") + cleaned = regexp.MustCompile(`\n{3,}`).ReplaceAllString(cleaned, "\n\n") + + return strings.TrimSpace(cleaned) +} + // GetEnv retrieves the value of an environment variable or returns a fallback value if not set func GetEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 4eeae9ac..95984b6e 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -155,6 +155,68 @@ func TestLogAndReturnError(t *testing.T) { } } +func TestRemoveReadmeSections(t *testing.T) { + tests := []struct { + name string + readme string + contains []string + excludes []string + }{ + { + name: "NoSectionsToRemove", + readme: "# My Module\n\nThis is a description.\n\n## Usage\n\nSome usage info.", + contains: []string{"# My Module", "## Usage", "Some usage info."}, + excludes: []string{}, + }, + { + name: "RemovesInputsSection", + readme: "# Module\n\n## Usage\n\nUsage content.\n\n## Inputs\n\n| Name | Type |\n|---|---|\n| var1 | string |", + contains: []string{"## Usage", "Usage content."}, + excludes: []string{"## Inputs", "var1"}, + }, + { + name: "RemovesOutputsSection", + readme: "# Module\n\n## Outputs\n\n| Name | Description |\n\n## Usage\n\nUsage info.", + contains: []string{"## Usage", "Usage info."}, + excludes: []string{"## Outputs"}, + }, + { + name: "RemovesDependenciesSection", + readme: "# Module\n\n## Dependencies\n\nSome deps.\n\n## Usage\n\nUsage.", + contains: []string{"## Usage", "Usage."}, + excludes: []string{"## Dependencies", "Some deps."}, + }, + { + name: "RemovesResourcesSection", + readme: "# Module\n\n## Resources\n\n| Name | Type |\n\n## Notes\n\nSome notes.", + contains: []string{"## Notes", "Some notes."}, + excludes: []string{"## Resources"}, + }, + { + name: "EmptyReadme", + readme: "", + contains: []string{}, + excludes: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := RemoveReadmeSections(tc.readme) + for _, c := range tc.contains { + if !strings.Contains(result, c) { + t.Errorf("expected result to contain %q, got %q", c, result) + } + } + for _, e := range tc.excludes { + if strings.Contains(result, e) { + t.Errorf("expected result to NOT contain %q, got %q", e, result) + } + } + }) + } +} + func TestExtractReadme(t *testing.T) { tests := []struct { name string From a0d92ad46662d90c4f78c044017d65a02c9ebcb0 Mon Sep 17 00:00:00 2001 From: Tung Huynh Date: Fri, 27 Mar 2026 00:09:20 -0700 Subject: [PATCH 3/3] update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 178f3e91..da9581db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 Readme section to `get_module_details` output [306](https://github.com/hashicorp/terraform-mcp-server/pull/306) FIXES