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 Readme section to `get_module_details` output [306](https://github.com/hashicorp/terraform-mcp-server/pull/306)

FIXES

Expand Down
9 changes: 9 additions & 0 deletions pkg/tools/registry/get_module_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
93 changes: 93 additions & 0 deletions pkg/tools/registry/get_module_details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
35 changes: 2 additions & 33 deletions pkg/tools/tfe/get_private_module_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
33 changes: 33 additions & 0 deletions pkg/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions pkg/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading