Key Takeaway
To run the Terraform MCP server safely, pin the 1.3.0 image, load only the toolsets you need, leave ENABLE_TF_OPERATIONS at its default of false, and set MCP_ORGANIZATION_ALLOWLIST on every shared HTTP deployment. Since 1.1.0 clients can no longer choose TFE_ADDRESS, and since 1.2.0 calls naming an unlisted organization are rejected.
I read the Terraform MCP server release notes expecting a feature list and found a security changelog. Each of the four releases from 1.0.0 to 1.3.0 touched what a caller, a spoofed proxy header or an over-eager agent can do, and 1.1.0 broke clients that had been working.
This post is a close reading of HashiCorp's release notes, reference page, deployment page and repository README, plus InfoQ's report of the general availability announcement. I have not run the server against a production HCP Terraform organisation, so every flag and default below traces to a page I read on 17 October 2026, and where two pages disagree I say so instead of smoothing it over.
What general availability actually put on your machine
The server is an open-source bridge between an MCP client and Terraform. InfoQ's 13 June report lists the headline uses: searching registry documentation, explaining plan files in plain language and answering questions about workspaces. It speaks two transports, stdio and StreamableHTTP, and can run locally for one developer or as a shared service for a team. The reference page groups its tools into three toolsets.
- registry: search public providers, modules and policies. No token needed.
- registry-private: private modules and providers. Registers only with a valid TFE_TOKEN.
- terraform: workspaces, runs, plans, variables, state, projects and teams. Also registers only with a valid TFE_TOKEN.
That split is the whole safety model in miniature. A server with no token is a documentation search tool; adding a token is the step that turns it into something that can touch infrastructure. So treat the token, not the container, as the thing you are granting. HashiCorp's own warning is blunt: the server should not be used with untrusted MCP clients or LLMs, because it may expose Terraform data.
Four releases in eleven weeks, read as a security log
The release notes run from 9 June to 25 August 2026, exactly eleven weeks. Read in order, they tell a story about a server that shipped as a local tool and was hardened as people started running it as a shared one.
| Release | Security-relevant change | What it means for you |
|---|---|---|
| 1.0.0, 9 June 2026 | Container runs as a non-root user. MCP_FORWARD_CLIENT_IP forwards the client IP in X-Forwarded-For on outbound requests. OpenTelemetry metrics record each client's name and version. get_sentinel_mock added. | The image suits Kubernetes policies that forbid root. You can see which MCP clients connect once metrics are on. |
| 1.1.0, 14 July 2026 | Breaking: TFE_ADDRESS in a header or query parameter returns 403, and X-Forwarded-For is no longer trusted by default. Adds the organization allowlist and force_unlock_workspace behind ENABLE_TF_OPERATIONS. | Set TFE_ADDRESS on the server. If a proxy fronts it, set MCP_XFF_TRUSTED_HOPS to the number of proxies you run. |
| 1.2.0, 4 August 2026 | Calls whose terraform_org_name is not in MCP_ORGANIZATION_ALLOWLIST are rejected. delete_project registers only with ENABLE_TF_OPERATIONS. TF_MCP_SHARED_SECRET sends an X-Tf-Mcp-Secret header to the backend. /health gains a version field. | The allowlist is enforced per call from here. Use /health to confirm which version is actually answering. |
| 1.3.0, 25 August 2026 | Six team tools: list_teams, get_team, create_team, add_team_member, grant_team_access and delete_team. delete_team registers only with ENABLE_TF_OPERATIONS. Also whoami and get_project. | The tool surface grows with every minor release. Recheck what your token can now reach after each upgrade. |
The 1.1.0 notes also list three fixes that matter if you ever ran 1.0.x as a shared HTTP service: allowlist validation now uses the same bearer token as the Terraform API requests, which closes a bypass through a conflicting TFE_TOKEN header; cross-tenant token reuse in stateless HTTP mode is fixed; and a session-scoped client cache that skipped per-token validation is fixed. I read those as the notes admitting the shared mode had holes before 1.1.0. The allowlist is listed under 1.1.0 and the per-call rejection under 1.2.0, so run 1.2.0 or later before you rely on it.
Three gates, and what each one does not stop
No single control is enough, because each one stops a different thing. The useful column in the table below is the right-hand one: what the gate leaves open is what the next gate has to cover.
| Control | What it stops | What it leaves open |
|---|---|---|
| Token scope (TFE_TOKEN, or the bearer token over HTTP) | Anything the token's own permissions forbid. With no token, the registry-private and terraform toolsets never register. | Everything the token is allowed to do. The deployment page says to limit its permissions, because the server will not narrow them for you. |
| --toolsets or --tools | Tools outside the list are not exposed to the client at all. | Anything inside the chosen toolsets. The two flags cannot be used together, and the default differs between two official pages (see below). |
| ENABLE_TF_OPERATIONS (default false) | Registration of action_run, delete_workspace_safely, force_unlock_workspace, delete_project and delete_team, and the auto_approve and is_destroy options of create_run. | Tools the docs do not list as gated, including create_workspace and update_workspace, which the same page describes as potentially destructive. |
| MCP_ORGANIZATION_ALLOWLIST (HTTP only) | Calls naming an organization outside the list. With it set, the bearer token must reach at least one listed organization; matching is case-insensitive. | stdio, where the flag is not offered. I also found nothing saying it filters tools that take no organization argument. |
| Network: loopback bind, TLS, a gateway | Unauthenticated reach from other hosts. HTTP mode binds to 127.0.0.1 by default. | A caller who is already an authorised client. Network position is not identity. |
Read top to bottom, the order of defence is: do not give the server a token it does not need, do not register tools you will not use, keep the destructive five off, bound the organizations, then bound the network. Skipping the first row because the later rows exist is the mistake the table is built to prevent.
A local setup that cannot reach production
For one developer on stdio, the safest config is the one that cannot do damage even if the model is manipulated. The README's own client example carries the token in the Docker arguments, which is fine for a first run and wrong for a file that gets synced to a dotfiles repository. There is also a wrinkle to pin down: the README lists registry as the default toolset, while the reference page says that without --toolsets the server enables all toolsets. With no token both give you the registry only, but with a token they may not, so name the toolset explicitly.
// Wrong: a literal token in a config file that gets synced or committed,
// and no decision about which toolsets load or how the server is bounded.
{
"mcpServers": {
"terraform": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "TFE_ADDRESS=https://app.terraform.io",
"-e", "TFE_TOKEN=paste-a-token-here",
"hashicorp/terraform-mcp-server:1.3.0"
]
}
}
}
// Right: values live in the client's env block (kept out of the args), the
// image is pinned, and ENABLE_TF_OPERATIONS is simply absent, so it stays false.
{
"mcpServers": {
"terraform": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "TFE_ADDRESS",
"-e", "TFE_TOKEN",
"hashicorp/terraform-mcp-server:1.3.0"
],
"env": {
"TFE_ADDRESS": "https://app.terraform.io",
"TFE_TOKEN": "paste-a-narrowly-scoped-token-here"
}
}
}
}If you only need documentation and module search, skip the token entirely. That is the strongest local setup, because the Terraform toolsets cannot register without one.
# Registry only, no token: the registry-private and terraform toolsets
# register only with a valid TFE_TOKEN, so this is a documentation search tool.
claude mcp add terraform -s user -t stdio -- \
docker run -i --rm hashicorp/terraform-mcp-server:1.3.0
# Binary form, when you want to name the toolset instead of inferring it.
# --toolsets and --tools cannot be combined.
terraform-mcp-server stdio --toolsets registryCreate a dedicated team token for agent use and name it for the agent. When something looks wrong in the HCP Terraform audit trail, a token called agent-readonly tells you who to ask; your personal token tells you nothing.
The five tools behind ENABLE_TF_OPERATIONS
ENABLE_TF_OPERATIONS is a registration switch, not a permission. When it is false, the destructive tools are not in the tool list, so a model cannot call them even if a prompt injection asks it to. The reference page names five tools that need it, and says create_run needs it to unlock its auto_approve and is_destroy options.
| Tool | Toolset | Effect according to the reference page | Gated by ENABLE_TF_OPERATIONS |
|---|---|---|---|
| action_run | terraform | Applies, discards or cancels runs; can apply or destroy infrastructure | Yes |
| delete_workspace_safely | terraform | Permanently deletes a workspace | Yes |
| force_unlock_workspace | terraform | Force-unlocks a stuck workspace; needs admin permission | Yes (added in 1.1.0) |
| delete_project | terraform | Deletes a project; fails if it still holds workspaces or Stacks | Yes (added in 1.2.0) |
| delete_team | terraform | Permanently deletes a team | Yes (added in 1.3.0) |
| create_run | terraform | Creates a run; its auto_approve and is_destroy options need the variable set | Partly |
| create_workspace, update_workspace | terraform | Described as potentially destructive, but not listed as requiring the variable | No |
The last row is the gap. With the variable off, an agent with a normal write token can still create and modify workspaces, and as I read the docs it can queue a run that stops short of auto-approval. That is a workable default, since plan analysis through the plan and run readers is the headline use, but it means the variable is not the whole answer. The token is: if the agent should only read, mint a token that can only read.
Running it as a shared HTTP service
The deployment page recommends an API gateway with authentication, TLS and IP allowlisting between clients and the server, and ships strict CORS with an empty origin list by default. The config below keeps the published port on loopback so only that gateway can reach it, sets the organization allowlist, and leaves ENABLE_TF_OPERATIONS unset. The README requires MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE for non-localhost deployments, so terminate TLS at the gateway or set those two variables.
# Shared service. TRANSPORT_HOST=0.0.0.0 is for inside the container;
# the host-side publish stays on loopback so only a gateway you run can reach it.
# ENABLE_TF_OPERATIONS is deliberately absent: its default is false.
docker run -d --name terraform-mcp --restart unless-stopped \
-p 127.0.0.1:8080:8080 \
-e TRANSPORT_MODE=streamable-http \
-e TRANSPORT_HOST=0.0.0.0 \
-e TFE_ADDRESS=https://app.terraform.io \
-e MCP_ORGANIZATION_ALLOWLIST=acme-platform,acme-sandbox \
-e MCP_CORS_MODE=strict \
-e MCP_REMOTE_IP_METHOD=RemoteAddr \
-e MCP_RATE_LIMIT_GLOBAL=5:10 \
-e MCP_RATE_LIMIT_SESSION=2:5 \
hashicorp/terraform-mcp-server:1.3.0
# Defaults are 10:20 global and 5:10 per session (rps:burst). The tighter
# values above are my choice for agents that can loop, not a HashiCorp number.
# A client then points at the endpoint (README form):
claude mcp add --transport http terraform http://localhost:8080/mcpOne trap is the client IP. Since 1.1.0 the server uses RemoteAddr and ignores X-Forwarded-For, which is correct, but it also means that behind a gateway every request appears to come from the gateway. If you want per-client addresses, set MCP_REMOTE_IP_METHOD to X-Forwarded-For and MCP_XFF_TRUSTED_HOPS to exactly the number of proxies you run. The README warns that any other value lets clients spoof their address.
Where this setup still leaks
The first cost is the upgrade. If any client of yours sends TFE_ADDRESS in a header or query parameter, 1.1.0 answers it with a 403, and a token in a query parameter is rejected with a 400. The fix is on the server side: set TFE_ADDRESS there and stop sending it. That is a better design, since a client that can choose the Terraform host can point the server at a host it controls, but it will look like an outage until you find the line in the notes.
The second cost is stdio. The organization allowlist flag is listed only under the streamable-http options, so a developer running a local stdio server with a broad token is protected by that token and nothing else. The third is TF_MCP_SHARED_SECRET, which is a static credential sent in a header; the README says to use it only over TLS.
ENABLE_TF_OPERATIONS is a server-wide switch. I found no per-client or per-session variant in the docs, so turning it on enables the destructive tools for every client of that instance at once. If you need them for a supervised session, run a second instance on a different port and keep it off the shared gateway.
None of this makes the server safe with an untrusted model. The controls bound the damage; they do not make prompt injection impossible, which is why HashiCorp's own advice is to avoid untrusted clients and LLMs altogether.
What to do on Monday
Five steps, in this order, each cheap enough to do before lunch.
- Pin the image tag to 1.3.0 and read the release notes between your current tag and the next before bumping a minor version.
- Start every instance on the registry toolset with no token, and add a token only when a task genuinely needs workspace data.
- When you do add a token, make it a dedicated team token with the narrowest permissions that work, and leave ENABLE_TF_OPERATIONS unset.
- For any shared deployment, set MCP_ORGANIZATION_ALLOWLIST, keep the published port on loopback behind a gateway with TLS, and set MCP_XFF_TRUSTED_HOPS to an exact number or leave it alone.
- Run the three checks below now and again after every upgrade.
# 1. What the running container actually has. If ENABLE_TF_OPERATIONS=true
# shows up on a box agents can reach, that is the finding.
docker inspect terraform-mcp \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(ENABLE_TF_OPERATIONS|MCP_ORGANIZATION_ALLOWLIST|TFE_SKIP_TLS_VERIFY|MCP_REMOTE_IP_METHOD|MCP_XFF_TRUSTED_HOPS)='
# 2. Which version answers. The /health response gained a version field in 1.2.0.
curl -s http://127.0.0.1:8080/health
# 3. The 1.1.0 breaking change as a smoke test. The release notes say a
# TFE_ADDRESS in a header or query parameter gets a 403. I have not
# observed this output myself; anything else is a reason to read the logs.
curl -s -o /dev/null -w "%{http_code}\n" \
"http://127.0.0.1:8080/mcp?TFE_ADDRESS=https://attacker.example"The first command is the one that earns its keep. It prints what the container really runs with, which is rarely what the compose file you remember says.
The rule I would carry out of this: treat an MCP server for Terraform as a credential with a tool menu attached. Every control in 1.1.0 to 1.3.0 either shrinks the menu or narrows the credential, so decide both deliberately, pin the version, and re-read the release notes before every minor upgrade.

