HashiCorp built an MCP server for writing Terraform. I built one for reviewing it
Automating Terraform Plan Audits: A Structured MCP Approach to IaC Risk Detection
Current Situation Analysis
Infrastructure as Code (IaC) review has reached a scaling bottleneck. As organizations adopt Terraform at enterprise scale, the terraform plan output has evolved from a manageable diff into a verbose execution log often spanning thousands of lines. The industry standard for reviewing these plans remains manual inspection or basic static analysis, both of which fail to address the core risk vector: the execution plan itself.
The fundamental pain point is signal-to-noise ratio. A production plan may contain 2,000 attribute updates for routine scaling events, masking a single catastrophic change, such as an IAM policy granting wildcard permissions, a security group opening to 0.0.0.0/0, or the replacement of a stateful database instance. Traditional code review tools analyze HCL syntax and diffs, but they lack the semantic context of the planned actions. They cannot distinguish between a safe update and a destructive replace operation on a resource holding persistent data.
HashiCorp's release of the official terraform-mcp-server signals a maturation of the Model Context Protocol (MCP) ecosystem for IaC. However, this tool focuses on the authoring phase: registry lookups, module documentation, and workspace management. It assists in writing infrastructure but leaves the review phase unaddressed. This gap creates a dangerous asymmetry where AI accelerates infrastructure creation without providing commensurate safety mechanisms for validation.
The overlooked reality is that terraform plan output contains sensitive metadata, including IAM relationships, account IDs, and network topology. Sending this data to hosted AI services introduces significant data leakage risks. Furthermore, most community attempts to bridge this gap wrap the Terraform CLI, asking the model to execute plans. This is an architectural anti-pattern for review; the model should reason about a pre-computed plan, not generate one, to ensure deterministic analysis and prevent unintended side effects.
WOW Moment: Key Findings
The shift from text-based analysis to structured data consumption via MCP fundamentally changes the economics and accuracy of IaC review. By decoupling the parsing logic from the model interaction, we can reduce token consumption, eliminate hallucination surfaces, and enforce strict security boundaries.
The following comparison highlights the operational differences between traditional review methods and a structured MCP-based audit pipeline:
| Approach | Review Latency | Risk Detection Rate | Hallucination Surface | Data Leakage Risk |
|---|---|---|---|---|
| Manual Review | High (Minutes/Hours) | Variable (Human fatigue) | N/A | Low (Local) |
| Static CLI Tools | Low (Seconds) | Low (Regex/Pattern only) | N/A | Low (Local) |
| LLM Text Paste | Medium (Seconds) | Medium (Context window limits) | High (Prose interpretation) | Critical (Data sent to cloud) |
| Structured MCP Audit | Low (Seconds) | High (Semantic rules + LLM reasoning) | Low (Data-driven prompts) | None (Local stdio transport) |
Why this matters: The structured MCP approach enables the model to act as a reasoning engine over verified data rather than a text generator. This allows for complex, diff-aware checks (e.g., detecting when a firewall rule widens from private CIDRs to public internet) that static tools miss, while maintaining the security posture required for production infrastructure data. The model receives concise, typed JSON payloads describing risks, allowing it to generate precise PR comments without ingesting the entire plan text.
Core Solution
The solution architecture separates the audit logic from the transport layer. This ensures the core analysis is testable, portable, and independent of the MCP protocol. The system ingests terraform show -json output, applies a multi-stage risk detection pipeline, and exposes the results via MCP tools for consumption by an LLM client.
Architecture Decisions
- Pure Function Parsing: The audit engine must be implemented as pure functions with no side effects or protocol dependencies. This allows the logic to be unit-tested, integrated into CI pipelines, or audited independently of the MCP server.
- Local Stdio Transport: The MCP server must operate exclusively over standard input/output. This guarantees that plan JSON, which contains sensitive infrastructure details, never leaves the local environment. No network listeners, no authentication tokens, no external endpoints.
- Structured Output Schema: Tools must return typed data structures rather than prose. This minimizes the context window required by the client model and reduces the probability of hallucination. The model receives facts and generates commentary, rather than interpreting raw text.
Implementation
The following TypeScript implementation demonstrates the decoupled architecture. It defines the audit engine, risk detectors, and the MCP transport layer.
1. Audit Engine and Risk Detectors
// audit-engine.ts
// Pure logic for analyzing Terraform plan JSON.
export interface ResourceChange {
address: string;
mode: string;
type: string;
action: 'create' | 'update' | 'delete' | 'replace';
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
after_unknown: Record<string, unknown>;
}
export interface AuditReport {
summary: {
total_changes: number;
creates: number;
updates: number;
deletes: number;
replaces: number;
};
findings: Finding[];
}
export interface Finding {
resource_address: string;
severity: 'info' | 'warning' | 'critical';
category: string;
description: string;
metadata?: Record<string, unknown>;
}
export class AuditEngine {
private detectors: RiskDetector[];
constructor(detectors: RiskDetector[]) {
this.detectors = detectors;
}
analyze(planJson: Record<string, unknown>): AuditReport {
const resourceChanges = this.extractResourceChanges(planJson);
const findings: Finding[] = [];
const summary = {
total_changes: resourceChanges.length,
creates: 0,
updates: 0,
deletes: 0,
replaces: 0,
};
for (const change of resourceChanges) {
summary[`${change.action}s` as keyof typeof summary]++;
for (const detector of this.detectors) {
const result = detector.check(change);
if (result) {
findings.push(result);
}
}
}
return { summary, findings };
}
private extractResourceChanges(plan: Record<string, unknown>): ResourceChange[] {
// Implementation extracts resource_changes array from terraform show -json
// Returns typed ResourceChange objects
return [];
}
}
export interface RiskDetector {
check(change: ResourceChange): Finding | null;
}
2. Specialized Risk Detectors
// detectors.ts
// Domain-specific risk detection logic.
export class StatefulDestroyDetector implements RiskDetector {
private readonly statefulTypes = new Set([
'aws_db_instance',
'aws_rds_cluster',
'google_sql_database_instance',
'google_compute_instance',
'azurerm_mssql_database',
]);
check(change: ResourceChange): Finding | null {
if (
this.statefulTypes.has(change.type) &&
(change.action === 'delete' || change.action === 'replace')
) {
const isGceWithLocalSsd =
change.type === 'google_compute_instance' &&
this.hasLocalSsd(change);
return {
resource_address: change.address,
severity: 'critical',
category: 'stateful_mutation',
description: isGceWithLocalSsd
? 'GCE instance with local SSDs scheduled for replace. This will destroy attached local disks and data.'
: 'Stateful resource scheduled for destroy/replace. Verify backup and migration strategy.',
metadata: { action: change.action },
};
}
return null;
}
private hasLocalSsd(change: ResourceChange): boolean {
// Check before/after for guest_accelerator or scratch_disk configurations
return false;
}
}
export class PublicExposureDetector implements RiskDetector {
check(change: ResourceChange): Finding | null {
if (change.type === 'google_compute_firewall' && change.action === 'update') {
const beforeRanges = this.extractSourceRanges(change.before);
const afterRanges = this.extractSourceRanges(change.after);
const newlyPublic = afterRanges.filter(
(r) => !beforeRanges.includes(r) && this.isPublicCidr(r)
);
if (newlyPublic.length > 0) {
return {
resource_address: change.address,
severity: 'critical',
category: 'network_exposure',
description: `Firewall source_ranges widened to include public CIDRs: ${newlyPublic.join(', ')}.`,
metadata: { new_ranges: newlyPublic },
};
}
}
return null;
}
private extractSourceRanges(attrs: Record<string, unknown> | null): string[] {
return attrs?.source_ranges as string[] || [];
}
private isPublicCidr(cidr: string): boolean {
return cidr === '0.0.0.0/0' || cidr === '::/0';
}
}
3. MCP Transport Layer
// mcp-server.ts
// MCP server exposing audit tools via stdio.
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
export class AuditMcpServer {
private server: Server;
private engine: AuditEngine;
constructor(engine: AuditEngine) {
this.engine = engine;
this.server = new Server(
{ name: 'infra-audit-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
this.setupHandlers();
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'execute_plan_audit',
description: 'Analyzes a Terraform plan JSON file and returns structured risk findings.',
inputSchema: {
type: 'object',
properties: {
plan_path: {
type: 'string',
description: 'Path to the terraform show -json output file.',
},
},
required: ['plan_path'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'execute_plan_audit') {
const planPath = request.params.arguments?.plan_path as string;
// Read and parse plan file securely
const planJson = await this.readPlanFile(planPath);
const report = this.engine.analyze(planJson);
return {
content: [
{
type: 'text',
text: JSON.stringify(report, null, 2),
},
],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
}
async start() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
}
private async readPlanFile(path: string): Promise<Record<string, unknown>> {
// File I/O implementation
return {};
}
}
Pitfall Guide
The Cloud Relay Trap
- Explanation: Deploying the MCP server as a hosted HTTP service or sending plan JSON to a third-party MCP endpoint. Plan JSON contains IAM policies, resource IDs, and network configurations that constitute a reconnaissance goldmine.
- Fix: Enforce local stdio transport exclusively. If CI integration is required, use a secure, ephemeral container with strict path allowlists and no external network access.
Ignoring Replace Semantics on Stateful Resources
- Explanation: Treating
replaceas equivalent toupdate. On resources likegoogle_compute_instancewith local SSDs oraws_db_instancewith specific storage configurations, a replace operation destroys the underlying disk and data. - Fix: Implement specific detectors that check for
replaceactions on stateful resource types and inspect attributes for local storage or non-backupable configurations.
- Explanation: Treating
Static Risk Lists Without Configuration
- Explanation: Hardcoding high-risk resource types in the source code. This prevents teams from adapting the audit to their specific blast-radius definitions or whitelisting safe patterns.
- Fix: Externalize risk profiles to a configuration file (e.g., YAML or JSON) that the audit engine loads at startup. Allow teams to define custom severity mappings and resource exclusions.
Prose-First Tool Outputs
- Explanation: Designing MCP tools that return natural language summaries. This forces the LLM to parse text, increasing token usage and the risk of misinterpretation. It also makes programmatic filtering impossible.
- Fix: Tools must return structured JSON. The LLM should be responsible for formatting the final review comment based on the structured data, not for interpreting raw tool output.
Diff Blindness in Security Checks
- Explanation: Checking only the
afterstate of a resource. A security group might appear open in the plan, but if it was already open, the risk is lower than if the plan is widening an existing rule. - Fix: Implement diff-aware checks that compare
beforeandafterattributes. Focus on changes in exposure, such as adding0.0.0.0/0to a previously private CIDR list.
- Explanation: Checking only the
Token Bloat via Full Plan Injection
- Explanation: Passing the entire
terraform show -jsonoutput to the LLM context. This wastes tokens, hits context limits, and exposes the model to irrelevant noise. - Fix: Use the MCP tool to filter and summarize the plan. The tool should return only the summary counts and the specific findings relevant to the review, keeping the context window lean.
- Explanation: Passing the entire
Lack of Unit Testing for Detectors
- Explanation: Coupling detection logic to the MCP transport makes it difficult to test edge cases or verify detector accuracy.
- Fix: Maintain the audit engine as a pure library. Write comprehensive unit tests for each detector using fixture files representing various plan scenarios. This ensures reliability before deployment.
Production Bundle
Action Checklist
- Generate Plan JSON: Run
terraform plan -out=tfplan && terraform show -json tfplan > plan.jsonto produce the audit input. - Configure MCP Client: Add the local MCP server command to your IDE or AI assistant configuration using
stdiotransport. - Define Risk Profile: Create a configuration file specifying high-risk resource types and custom severity rules for your organization.
- Execute Audit: Invoke the
execute_plan_audittool via the MCP client, pointing to the plan JSON file. - Review Structured Output: Analyze the returned JSON report for critical findings, focusing on stateful mutations and exposure changes.
- Generate PR Feedback: Ask the model to draft PR comments based on the structured findings, ensuring tone and context match your team's standards.
- Validate Stateful Changes: For any critical findings involving stateful resources, manually verify backup status and migration plans before applying.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Local Development | Local Stdio MCP Server | Zero latency, no data leakage, immediate feedback during coding. | None (Local compute). |
| Team CI/CD Pipeline | Secure Containerized MCP Wrapper | Automates review gates without exposing secrets; requires strict isolation. | Low (CI runner costs). |
| Compliance Audit | Structured MCP + Policy Engine | Combines risk detection with regulatory checks (e.g., SOC2, HIPAA) via integrated tools. | Medium (Policy engine licensing). |
| Multi-Cloud Enterprise | Configurable Risk Profiles | Allows per-environment or per-team risk definitions; centralizes governance. | Low (Configuration management). |
Configuration Template
MCP Client Configuration (mcp-config.json)
{
"mcpServers": {
"infra-audit": {
"command": "node",
"args": ["./dist/mcp-server.js"],
"env": {
"AUDIT_CONFIG_PATH": "./audit-rules.yaml"
},
"transport": "stdio"
}
}
}
Risk Profile Configuration (audit-rules.yaml)
risk_profiles:
default:
high_risk_types:
- aws_iam_role
- aws_iam_policy
- google_compute_firewall
- aws_security_group
- azurerm_network_security_group
stateful_types:
- aws_db_instance
- google_sql_database_instance
- azurerm_mariadb_server
public_cidrs:
- "0.0.0.0/0"
- "::/0"
production:
severity_overrides:
aws_s3_bucket: "critical"
google_storage_bucket: "critical"
exclusions:
- address_regex: ".*-test-.*"
reason: "Test resources excluded from blast-radius checks"
Quick Start Guide
- Install Dependencies: Ensure Node.js is installed. Initialize the project and install the MCP SDK:
npm install @modelcontextprotocol/sdk. - Create Audit Engine: Implement the
AuditEngineand detectors as shown in the Core Solution. Export the logic as a reusable module. - Build MCP Server: Create the server script that instantiates the engine and registers the
execute_plan_audittool. Ensure it usesStdioServerTransport. - Generate Test Plan: Run
terraform planin a test directory and export to JSON. Use this to verify the server returns correct findings. - Connect Client: Configure your MCP-compatible client (e.g., Claude Desktop, Cursor, or custom script) with the server command. Query the model: "Audit the plan at
plan.jsonand list critical risks."
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
