Skip to content
Draft
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
32 changes: 32 additions & 0 deletions .github/workflows/acceptance_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0

name: Acceptance Tests
on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
acceptance-test:
runs-on: ubuntu-latest

steps:
- name: Check out code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd

- name: Set up Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
with:
go-version-file: "go.mod"

- name: Download dependencies
run: make deps

- name: Run acceptance tests
env:
TFE_TOKEN: ${{ secrets.TFE_TOKEN }}
run: make test-acc
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ crt-build:

# Run tests
test:
$(GO) test -v ./...
$(GO) test -tags '!acceptance' -v ./...

# Run e2e tests
test-e2e:
@trap '$(MAKE) cleanup-test-containers' EXIT; $(GO) test -v --tags e2e ./e2e

# Run acceptance tests
test-acc:
$(GO) test -tags 'acceptance' -v ./test/acceptance/... -run '$(RUN_TESTS)'

# Clean build artifacts
clean:
rm -f $(BINARY_NAME)
Expand Down
11 changes: 1 addition & 10 deletions cmd/terraform-mcp-server/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import (
"time"

"github.com/hashicorp/terraform-mcp-server/pkg/client"
"github.com/hashicorp/terraform-mcp-server/pkg/resources"
"github.com/hashicorp/terraform-mcp-server/pkg/tools"
"github.com/hashicorp/terraform-mcp-server/version"
"github.com/mark3labs/mcp-go/server"
log "github.com/sirupsen/logrus"
Expand Down Expand Up @@ -129,7 +127,7 @@ func initLogger(outPath string) (*log.Logger, error) {
return log.New(), nil
}

file, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
file, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
Expand All @@ -141,13 +139,6 @@ func initLogger(outPath string) (*log.Logger, error) {
return logger, nil
}

// registerToolsAndResources registers tools and resources with the MCP server
func registerToolsAndResources(hcServer *server.MCPServer, logger *log.Logger) {
tools.RegisterTools(hcServer, logger)
resources.RegisterResources(hcServer, logger)
resources.RegisterResourceTemplates(hcServer, logger)
}

func serverInit(ctx context.Context, hcServer *server.MCPServer, logger *log.Logger) error {
stdioServer := server.NewStdioServer(hcServer)
stdLogger := stdlog.New(logger.Writer(), "stdioserver", 0)
Expand Down
59 changes: 4 additions & 55 deletions cmd/terraform-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,73 +13,29 @@ import (
"strings"
"syscall"

"github.com/hashicorp/terraform-mcp-server/pkg/client"
tfmcpserver "github.com/hashicorp/terraform-mcp-server/pkg/server"
"github.com/hashicorp/terraform-mcp-server/version"

"github.com/mark3labs/mcp-go/server"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

//go:embed instructions.md
var instructions string

func runHTTPServer(logger *log.Logger, host string, port string, endpointPath string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

hcServer := NewServer(version.Version, logger)
registerToolsAndResources(hcServer, logger)

hcServer := tfmcpserver.NewServer(version.Version, logger)
return streamableHTTPServerInit(ctx, hcServer, logger, host, port, endpointPath)
}

func runStdioServer(logger *log.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

hcServer := NewServer(version.Version, logger)
registerToolsAndResources(hcServer, logger)

hcServer := tfmcpserver.NewServer(version.Version, logger)
return serverInit(ctx, hcServer, logger)
}

func NewServer(version string, logger *log.Logger, opts ...server.ServerOption) *server.MCPServer {
// Create rate limiting middleware with environment-based configuration
rateLimitConfig := client.LoadRateLimitConfigFromEnv()
rateLimitMiddleware := client.NewRateLimitMiddleware(rateLimitConfig, logger)

// Add default options
defaultOpts := []server.ServerOption{
server.WithToolCapabilities(true),
server.WithResourceCapabilities(true, true),
server.WithInstructions(instructions),
server.WithToolHandlerMiddleware(rateLimitMiddleware.Middleware()),
server.WithElicitation(),
}
opts = append(defaultOpts, opts...)

// Create hooks for session management
hooks := &server.Hooks{}
hooks.AddOnRegisterSession(func(ctx context.Context, session server.ClientSession) {
client.NewSessionHandler(ctx, session, logger)
})
hooks.AddOnUnregisterSession(func(ctx context.Context, session server.ClientSession) {
client.EndSessionHandler(ctx, session, logger)
})

// Add hooks to options
opts = append(opts, server.WithHooks(hooks))

// Create a new MCP server
s := server.NewMCPServer(
"terraform-mcp-server",
version,
opts...,
)
return s
}

// runDefaultCommand handles the default behavior when no subcommand is provided
func runDefaultCommand(cmd *cobra.Command, _ []string) {
// Default to stdio mode when no subcommand is provided
Expand Down Expand Up @@ -135,14 +91,7 @@ func shouldUseStreamableHTTPMode() bool {
// shouldUseStatelessMode returns true if the MCP_SESSION_MODE environment variable is set to "stateless"
func shouldUseStatelessMode() bool {
mode := strings.ToLower(os.Getenv("MCP_SESSION_MODE"))

// Explicitly check for "stateless" value
if mode == "stateless" {
return true
}

// All other values (including empty string, "stateful", or any other value) default to stateful mode
return false
return mode == "stateless"
}

// getHTTPPort returns the port from environment variables or default
Expand Down
File renamed without changes.
60 changes: 60 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package server

import (
"context"
_ "embed"

"github.com/hashicorp/terraform-mcp-server/pkg/client"
"github.com/hashicorp/terraform-mcp-server/pkg/resources"
"github.com/hashicorp/terraform-mcp-server/pkg/tools"
"github.com/mark3labs/mcp-go/server"
log "github.com/sirupsen/logrus"
)

//go:embed instructions.md
var instructions string

func NewServer(version string, logger *log.Logger, opts ...server.ServerOption) *server.MCPServer {
// Create rate limiting middleware with environment-based configuration
rateLimitConfig := client.LoadRateLimitConfigFromEnv()
rateLimitMiddleware := client.NewRateLimitMiddleware(rateLimitConfig, logger)

// Add default options
defaultOpts := []server.ServerOption{
server.WithToolCapabilities(true),
server.WithResourceCapabilities(true, true),
server.WithInstructions(instructions),
server.WithToolHandlerMiddleware(rateLimitMiddleware.Middleware()),
server.WithElicitation(),
}
opts = append(defaultOpts, opts...)

// Create hooks for session management
hooks := &server.Hooks{}
hooks.AddOnRegisterSession(func(ctx context.Context, session server.ClientSession) {
client.NewSessionHandler(ctx, session, logger)
})
hooks.AddOnUnregisterSession(func(ctx context.Context, session server.ClientSession) {
client.EndSessionHandler(ctx, session, logger)
})

// Add hooks to options
opts = append(opts, server.WithHooks(hooks))

// Create a new MCP server
s := server.NewMCPServer(
"terraform-mcp-server",
version,
opts...,
)

registerToolsAndResources(s, logger)
return s
}

// registerToolsAndResources registers tools and resources with the MCP server
func registerToolsAndResources(hcServer *server.MCPServer, logger *log.Logger) {
tools.RegisterTools(hcServer, logger)
resources.RegisterResources(hcServer, logger)
resources.RegisterResourceTemplates(hcServer, logger)
}
13 changes: 13 additions & 0 deletions test/acceptance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Acceptance Test Suite

The acceptance test suite exercises the high level functionality of the MCP server against the real registry and TFE/TFC APIs.

Tests added to this suite should make meaningful assertions to ensure that the behaviour of the server actually makes sense.

## Run the acceptance test suite

To run these tests an active token for accessing the TFE/TFC API is required.

```bash
TFE_TOKEN=<token> make test-acc
```
91 changes: 91 additions & 0 deletions test/acceptance/acceptance_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package acceptance

import (
"context"
"math/rand"
"regexp"
"strconv"
"testing"

"github.com/mark3labs/mcp-go/client"

"github.com/mark3labs/mcp-go/mcp"
)

// ToolAcceptanceTest encapsulates one test we want to run against an MCP server tool
type ToolAcceptanceTest struct {
// Name of what we're testing
Name string

// Description of the test
Description string

// ToolName is the name of the the tool we want to test
ToolName string

// Arguments we want to pass into the tool call
Arguments map[string]any

// ExpectError is a regexp to match expected error message
ExpectError *regexp.Regexp

// ExpectTextContent is a regexp to match expected text content
ExpectTextContent *regexp.Regexp

// Checks are arbitrary functions we can add to check behaviour
Checks []ToolTestCheck

// Skip the test
Skip bool
}

type ToolAcceptanceTestSuite []ToolAcceptanceTest

type ToolTestCheck func(t *testing.T, res *mcp.CallToolResult)

func runAcceptanceTest(t *testing.T, ctx context.Context, at ToolAcceptanceTest, c *client.Client) {
t.Run(at.Name, func(t *testing.T) {
if at.Skip {
t.Skip("Skip set to true")
return
}

res, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: at.ToolName,
Arguments: at.Arguments,
},
})

if err != nil {
if at.ExpectError != nil {
if !at.ExpectError.MatchString(err.Error()) {
t.Fatalf("Expected error from tool %q to match %q, got: %q", at.ToolName, at.ExpectError, err.Error())
}
} else {
t.Fatalf("Error when calling tool %q: %v", at.ToolName, err)
}
} else if at.ExpectError != nil {
t.Fatalf("Expected tool %q to error but it was called successfully", at.ToolName)
}

if at.ExpectTextContent != nil {
content, ok := res.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("Response did not contain text content")
}

if !at.ExpectTextContent.MatchString(content.Text) {
t.Fatalf("Expected text from tool %q to match %q, got: %q", at.ToolName, at.ExpectTextContent, content.Text)
}
}

for _, check := range at.Checks {
check(t, res)
}
})
}

func randomName(name string) string {
return name + "-" + strconv.FormatInt(rand.Int63(), 36)[:5]
}
Loading
Loading