·

Terraform MCP With Claude Code CLI and VS Code

Set up Terraform MCP in Claude Code CLI and VS Code so your AI agent can plan, review, and manage infrastructure as code right from your editor.

Claude Code is the strongest fit of the four agents covered in this course for Terraform work, mostly because its permission model maps cleanly onto the plan-review-not-blind-apply principle from the previous topic: you can allow terraform_plan and terraform_validate unattended while requiring explicit approval for anything that mutates state or calls out to terraform_apply. This topic covers wiring the server into both the CLI and the VS Code extension, then walks two real workflows — reviewing a plan diff, and generating a module from scratch — end to end.

Installing and Connecting Terraform MCP to Claude Code

Register the server at project scope so it's committed to .mcp.json and every teammate (and CI, if you run headless review there) gets the same tools:

claude mcp add terraform --scope project -- \
  docker run -i --rm \
    -v "$(pwd)":/workspace \
    -w /workspace \
    -e TF_IN_AUTOMATION=true \
    -e TF_WORKSPACE=staging \
    hashicorp/terraform-mcp-server

That command targets the registry-lookup server. If you also want init/validate/plan/state tools, add a second entry for your CLI-wrapping server — most teams run this as a local binary rather than Docker so it shares the host's terraform install and provider plugin cache:

claude mcp add terraform-cli --scope project -- \
  terraform-mcp-server --allowed-dirs "$(pwd)/infra" --deny-glob "**/*.tfstate"

Verify both connected and inspect the merged toolset:

claude mcp list

Check .mcp.json got written correctly — this is the file you commit:

{
  "mcpServers": {
    "terraform": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "-v", "${PWD}:/workspace", "-w", "/workspace",
                "-e", "TF_IN_AUTOMATION=true", "-e", "TF_WORKSPACE=staging",
                "hashicorp/terraform-mcp-server"]
    },
    "terraform-cli": {
      "command": "terraform-mcp-server",
      "args": ["--allowed-dirs", "${PWD}/infra", "--deny-glob", "**/*.tfstate"]
    }
  }
}

Now set the permission split that makes autonomous review safe. In .claude/settings.json, allow the read-only tools and require confirmation on anything that mutates:

{
  "permissions": {
    "allow": [
      "mcp__terraform-cli__terraform_validate",
      "mcp__terraform-cli__terraform_plan",
      "mcp__terraform-cli__terraform_state_list",
      "mcp__terraform-cli__terraform_state_show",
      "mcp__terraform__search_providers",
      "mcp__terraform__get_provider_docs"
    ],
    "ask": [
      "mcp__terraform-cli__terraform_apply"
    ]
  }
}

With this in place, Claude can validate, plan, and inspect state freely in the middle of a task without interrupting you, but it stops and asks the moment anything touches apply.

Tips
- Register two servers rather than hoping one does everything — registry lookup and CLI execution are genuinely separate concerns with separate tool surfaces in most implementations.
- Commit .mcp.json at project scope so CI-based plan review (covered in topic 6 of this module) uses the identical tool configuration as local development.
- Put terraform_apply in ask, not allow, even if your server supports a confirmation token — the interactive prompt is a second, human-visible gate on top of whatever the server does internally.


Letting AI Read and Explain a Terraform Plan Diff Before You Apply

The highest-value everyday use of Terraform MCP in Claude Code is turning a wall of plan output into a reviewable summary before you run apply yourself. Say you changed an instance type and added a read replica:

resource "aws_db_instance" "primary" {
  identifier           = "acme-prod-primary"
  instance_class       = "db.r6g.xlarge" # was db.r6g.large
  allocated_storage    = 200
  engine               = "postgres"
  engine_version       = "16.1"
  multi_az             = true
}

resource "aws_db_instance" "replica" {
  identifier             = "acme-prod-replica"
  replicate_source_db    = aws_db_instance.primary.identifier
  instance_class         = "db.r6g.large"
}

Prompt Claude to plan and interpret it, not just run the command:

Run terraform_plan for infra/environments/prod using prod.tfvars.
Summarize every change by resource, classify each as additive, in-place
update, or replacement, and flag anything that will cause a restart
or a brief connection interruption.

Claude calls the tool, gets back structured plan JSON, and should respond with something like:

Plan: 1 to add, 1 to change, 0 to destroy.

- aws_db_instance.replica  → CREATE (new resource, no risk to primary)
- aws_db_instance.primary  → UPDATE in place
    instance_class: db.r6g.large -> db.r6g.xlarge
    This is an in-place instance class change on RDS but it DOES require
    a reboot unless apply_immediately=false and you're inside a
    maintenance window — expect 1-2 min of connection interruption.

Recommendation: schedule this during your Tuesday 02:00 UTC maintenance
window, not ad hoc.

That last line — the operational judgment, not just the diff restatement — is what separates a useful plan-review agent from one that's just pretty-printing JSON. Push for it explicitly in your prompt; models default to restating the diff unless asked to classify risk.

For a raw look at what the agent is working from, the actual CLI output looks like this:

Terraform will perform the following actions:

  # aws_db_instance.primary will be updated in-place
  ~ resource "aws_db_instance" "primary" {
        id             = "acme-prod-primary"
      ~ instance_class = "db.r6g.large" -> "db.r6g.xlarge"
        tags           = {}
    }

  # aws_db_instance.replica will be created
  + resource "aws_db_instance" "replica" {
      + identifier          = "acme-prod-replica"
      + instance_class      = "db.r6g.large"
      + replicate_source_db = "acme-prod-primary"
    }

Plan: 1 to add, 1 to change, 0 to destroy.

Tips
- Explicitly ask for a risk classification (additive / in-place / replace, plus downtime implications) — the model will otherwise just narrate the diff back to you, which adds little over reading it yourself.
- Cross-check any "no downtime" claim the agent makes against provider docs for that specific resource — RDS instance class changes, for instance, reboot even though Terraform calls it an in-place update.
- Keep -out=tfplan in every plan call so the exact reviewed plan is what gets applied, not a fresh plan computed moments later against drifted state.


Generating Reusable Modules with Variables, Outputs, and Validation

Module authoring is where the registry-lookup tools pay for themselves — the agent can check real provider schema instead of guessing argument names from stale training data. Ask for a module and require it to check docs first:

Before writing any HCL, call get_provider_docs for aws_lambda_function
on provider version ~> 5.60 to confirm current required and optional
arguments. Then write a reusable module at modules/lambda-function/
with variables for function_name, runtime, memory_size, timeout,
and environment variables, sane defaults, and outputs for the
function ARN and invoke ARN.

A well-formed result:

variable "function_name" {
  type = string
}

variable "runtime" {
  type    = string
  default = "python3.12"
}

variable "memory_size" {
  type    = number
  default = 256

  validation {
    condition     = var.memory_size >= 128 && var.memory_size <= 10240
    error_message = "memory_size must be between 128 and 10240 MB."
  }
}

variable "timeout" {
  type    = number
  default = 30
}

variable "environment_variables" {
  type    = map(string)
  default = {}
}
resource "aws_lambda_function" "this" {
  function_name = var.function_name
  runtime       = var.runtime
  memory_size   = var.memory_size
  timeout       = var.timeout
  handler       = "index.handler"
  role          = aws_iam_role.lambda_exec.arn
  filename      = data.archive_file.package.output_path

  environment {
    variables = var.environment_variables
  }
}

resource "aws_iam_role" "lambda_exec" {
  name = "${var.function_name}-exec"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}
output "function_arn" {
  value = aws_lambda_function.this.arn
}

output "invoke_arn" {
  value = aws_lambda_function.this.invoke_arn
}

Then close the loop — have Claude run validate and a plan against a throwaway example root module before you trust it:

cd examples/lambda-function-basic && terraform init -backend=false && terraform validate

The validation block on memory_size is a detail that only shows up when the agent actually checked the provider schema rather than pattern-matching a generic Lambda example — worth spot-checking as a signal the docs lookup happened.

Tips
- Force the docs lookup with an explicit instruction ("call get_provider_docs before writing HCL") — otherwise the agent reaches for whatever Lambda module shape appeared most often in training data, which drifts from current provider syntax.
- Add validation blocks for numeric/enum variables yourself if the agent skips them; they turn a bad terraform plan into a bad terraform validate, which is cheaper to catch.
- Run the module against a disposable example root (-backend=false, local state) before wiring it into a real environment — never validate a new module directly inside a stateful root module.


Detecting Drift Between State and Real Infrastructure

Drift — someone clicked a change in the AWS console, or a Lambda auto-scaled a setting Terraform doesn't manage — is invisible until you ask. terraform plan -refresh-only is the tool for finding it without proposing any changes to fix it yet:

terraform plan -refresh-only -out=refresh.plan

Wire this into an MCP call and ask Claude to interpret the refresh-only diff specifically, since a ~ here means "state disagreed with reality," not "we're about to change something":

Run a refresh-only plan for infra/environments/prod. For every
resource showing drift, tell me what changed outside Terraform,
and whether reconciling it (updating our HCL to match) or
correcting it (letting the next apply revert it) is the right call.

Typical refresh-only output the agent reasons over:

Note: Objects have changed outside of Terraform

  # aws_security_group.app_sg has changed
  ~ resource "aws_security_group" "app_sg" {
        id = "sg-0abc123"
      ~ ingress {
          ~ cidr_blocks = [
              - "10.0.0.0/16",
              + "10.0.0.0/16",
              + "203.0.113.4/32",
            ]
        }
    }

This is a refresh-only plan, so Terraform will not take any actions
to undo these changes.

A good agent response distinguishes the two remediation paths instead of picking one silently:

Drift detected on aws_security_group.app_sg: someone added
203.0.113.4/32 to ingress outside Terraform.

Two options:
1. Reconcile — add this CIDR to the security_group HCL if it's a
   legitimate, intended access rule (e.g. an ops engineer's home IP
   for an incident).
2. Correct — leave HCL as-is; the next normal apply will remove
   this rule, since it's not declared.

I can't tell which is intended from the plan alone — check with
whoever added it, or check CloudTrail for the change event, before
choosing.

That honesty — "I can't tell which is intended" — is the right output. An agent that silently picks reconcile-or-correct on security-relevant drift is making a security decision without the context to make it safely.

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=app_sg \
  --max-results 5

Tips
- Run -refresh-only on a schedule (nightly CI job) rather than only when someone remembers to check — drift compounds silently and the diff gets harder to interpret the longer it sits.
- Treat drift on security groups, IAM policies, and network ACLs as an incident-review item, not a routine reconcile — cross-check CloudTrail before letting an agent (or yourself) decide reconcile vs. correct.
- Never let a refresh-only plan auto-chain into a regular apply in an agent loop; the two have fundamentally different intents and merging them removes your only checkpoint for reviewing unexpected external changes.


Tips

Tips
- Split registry-lookup and CLI-execution servers into two .mcp.json entries and scope permissions per-tool — allow read/plan tools, require confirmation on apply.
- Push the agent past diff restatement toward risk classification and operational recommendations; that's the actual value-add over reading terraform plan yourself.
- Use -refresh-only explicitly for drift checks and keep it separate from your normal plan/apply loop — conflating them removes your visibility into out-of-band changes.