@downatthebottomofthemolehole/terraform-best-practices-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carl Dawson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # terraform-best-practices-mcp
2
+
3
+ An MCP server that helps teams produce better Terraform by combining:
4
+
5
+ - CLI analysis: `tflint`, `checkov`, `trivy`, `kics`, `infracost`
6
+ - Best-practice retrieval from `terraform-best-practices.com`
7
+ - Cloud provider guidance for `azure`, `aws`, and `gcp`
8
+ - Terraform Registry guidance for providers/resources/modules
9
+
10
+ ## Tool Catalog
11
+
12
+ - `run_tflint`
13
+ - `run_checkov`
14
+ - `run_trivy`
15
+ - `run_kics`
16
+ - `run_infracost`
17
+ - `fetch_terraform_best_practices`
18
+ - `fetch_provider_best_practices`
19
+ - `fetch_terraform_registry_guidance`
20
+
21
+ ## Prerequisites
22
+
23
+ - Node.js 24+
24
+ - Optional CLIs available on `PATH` for command tools:
25
+ - `tflint`
26
+ - `checkov`
27
+ - `trivy`
28
+ - `kics`
29
+ - `infracost`
30
+
31
+ If a CLI is missing, the server returns installation guidance instead of failing silently.
32
+
33
+ ## Setup
34
+
35
+ ```bash
36
+ npm install
37
+ npm run build
38
+ ```
39
+
40
+ ## Run
41
+
42
+ ```bash
43
+ npm run dev
44
+ ```
45
+
46
+ For production-style execution:
47
+
48
+ ```bash
49
+ npm run build
50
+ npm start
51
+ ```
52
+
53
+ ## Publish
54
+
55
+ This package is configured for npm as:
56
+
57
+ - `@downatthebottomofthemolehole/terraform-best-practices-mcp-server`
58
+
59
+ To publish:
60
+
61
+ ```bash
62
+ npm publish --access public
63
+ ```
64
+
65
+ ## MCP Configuration
66
+
67
+ This project includes `.vscode/mcp.json`:
68
+
69
+ ```json
70
+ {
71
+ "servers": {
72
+ "terraform-best-practices-mcp": {
73
+ "type": "stdio",
74
+ "command": "npm",
75
+ "args": ["run", "dev"]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Registry metadata files used by your other MCP repos are also included:
82
+
83
+ - `mcp.json`
84
+ - `server.json`
85
+
86
+ ## References
87
+
88
+ - [MCP org](https://github.com/modelcontextprotocol)
89
+ - [MCP docs](https://modelcontextprotocol.io/docs)
90
+ - [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
91
+ - [terraform-best-practices](https://www.terraform-best-practices.com/)
92
+ - [Terraform Registry](https://registry.terraform.io/)
package/dist/index.js ADDED
@@ -0,0 +1,79 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
4
+ import { allTools } from "./tools/index.js";
5
+ const server = new Server({
6
+ name: "terraform-best-practices-mcp",
7
+ version: "0.1.0"
8
+ }, {
9
+ capabilities: {
10
+ tools: {}
11
+ }
12
+ });
13
+ const toolMap = new Map(allTools.map((tool) => [tool.name, tool]));
14
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
15
+ return {
16
+ tools: allTools.map((tool) => ({
17
+ name: tool.name,
18
+ description: tool.description,
19
+ inputSchema: tool.inputSchemaJson
20
+ }))
21
+ };
22
+ });
23
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
24
+ const tool = toolMap.get(request.params.name);
25
+ if (!tool) {
26
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
27
+ }
28
+ const parsed = tool.inputSchema.safeParse(request.params.arguments ?? {});
29
+ if (!parsed.success) {
30
+ const issues = parsed.error.issues
31
+ .map((issue) => {
32
+ const path = issue.path.length > 0 ? issue.path.join(".") : "input";
33
+ return `${path}: ${issue.message}`;
34
+ })
35
+ .join("; ");
36
+ return {
37
+ isError: true,
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: `Invalid arguments for ${tool.name}: ${issues}`
42
+ }
43
+ ]
44
+ };
45
+ }
46
+ try {
47
+ const text = await tool.run(parsed.data);
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text
53
+ }
54
+ ]
55
+ };
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ return {
60
+ isError: true,
61
+ content: [
62
+ {
63
+ type: "text",
64
+ text: `Tool execution failed: ${message}`
65
+ }
66
+ ]
67
+ };
68
+ }
69
+ });
70
+ async function main() {
71
+ const transport = new StdioServerTransport();
72
+ await server.connect(transport);
73
+ console.error("terraform-best-practices-mcp running on stdio");
74
+ }
75
+ main().catch((error) => {
76
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
77
+ console.error(`Fatal startup error: ${message}`);
78
+ process.exit(1);
79
+ });
@@ -0,0 +1,86 @@
1
+ import { spawn } from "node:child_process";
2
+ const DEFAULT_TIMEOUT_MS = 120_000;
3
+ const DEFAULT_MAX_OUTPUT_BYTES = 2_000_000;
4
+ export class CommandNotFoundError extends Error {
5
+ constructor(command) {
6
+ super(`Command not found: ${command}`);
7
+ this.name = "CommandNotFoundError";
8
+ }
9
+ }
10
+ export async function runCommand(command, args, options = {}) {
11
+ const cwd = options.cwd ?? process.cwd();
12
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
13
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
14
+ return await new Promise((resolve, reject) => {
15
+ const child = spawn(command, args, {
16
+ cwd,
17
+ env: options.env ?? process.env,
18
+ shell: false,
19
+ stdio: ["ignore", "pipe", "pipe"]
20
+ });
21
+ let stdout = "";
22
+ let stderr = "";
23
+ let timedOut = false;
24
+ let outputTruncated = false;
25
+ let killIssued = false;
26
+ let settled = false;
27
+ const finish = (done) => {
28
+ if (settled) {
29
+ return;
30
+ }
31
+ settled = true;
32
+ clearTimeout(timer);
33
+ done();
34
+ };
35
+ const stopProcess = () => {
36
+ if (killIssued || child.killed) {
37
+ return;
38
+ }
39
+ killIssued = true;
40
+ child.kill("SIGTERM");
41
+ };
42
+ const appendOutput = (current, chunk) => {
43
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
44
+ const next = current + text;
45
+ if (Buffer.byteLength(next, "utf8") > maxOutputBytes) {
46
+ outputTruncated = true;
47
+ stopProcess();
48
+ return next.slice(0, maxOutputBytes);
49
+ }
50
+ return next;
51
+ };
52
+ const timer = setTimeout(() => {
53
+ timedOut = true;
54
+ stopProcess();
55
+ }, timeoutMs);
56
+ child.stdout?.on("data", (chunk) => {
57
+ stdout = appendOutput(stdout, chunk);
58
+ });
59
+ child.stderr?.on("data", (chunk) => {
60
+ stderr = appendOutput(stderr, chunk);
61
+ });
62
+ child.once("error", (error) => {
63
+ finish(() => {
64
+ if (error.code === "ENOENT") {
65
+ reject(new CommandNotFoundError(command));
66
+ return;
67
+ }
68
+ reject(error);
69
+ });
70
+ });
71
+ child.once("close", (code) => {
72
+ finish(() => {
73
+ resolve({
74
+ command,
75
+ args,
76
+ cwd,
77
+ exitCode: code ?? -1,
78
+ stdout: stdout.trim(),
79
+ stderr: stderr.trim(),
80
+ timedOut,
81
+ outputTruncated
82
+ });
83
+ });
84
+ });
85
+ });
86
+ }
@@ -0,0 +1,19 @@
1
+ export async function fetchText(url, timeoutMs = 15_000) {
2
+ const controller = new AbortController();
3
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
4
+ try {
5
+ const response = await fetch(url, {
6
+ signal: controller.signal,
7
+ headers: {
8
+ "user-agent": "terraform-best-practices-mcp/0.1.0"
9
+ }
10
+ });
11
+ if (!response.ok) {
12
+ throw new Error(`Request failed with status ${response.status}`);
13
+ }
14
+ return await response.text();
15
+ }
16
+ finally {
17
+ clearTimeout(timeout);
18
+ }
19
+ }
@@ -0,0 +1,39 @@
1
+ export function toPlainText(value) {
2
+ return value
3
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
4
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
5
+ .replace(/<[^>]+>/g, " ")
6
+ .replace(/&nbsp;/gi, " ")
7
+ .replace(/&amp;/gi, "&")
8
+ .replace(/&quot;/gi, '"')
9
+ .replace(/&#39;/gi, "'")
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ }
13
+ export function keywordSnippet(text, keywords, maxChars = 2_000) {
14
+ const cleaned = text.trim();
15
+ if (!cleaned) {
16
+ return "";
17
+ }
18
+ if (!keywords.length) {
19
+ return cleaned.slice(0, maxChars);
20
+ }
21
+ const loweredKeywords = keywords
22
+ .map((keyword) => keyword.trim().toLowerCase())
23
+ .filter((keyword) => keyword.length > 0);
24
+ const sentences = cleaned.split(/(?<=[.!?])\s+/);
25
+ const matches = sentences.filter((sentence) => {
26
+ const lowered = sentence.toLowerCase();
27
+ return loweredKeywords.some((keyword) => lowered.includes(keyword));
28
+ });
29
+ const source = matches.length > 0 ? matches : sentences;
30
+ let output = "";
31
+ for (const sentence of source) {
32
+ const candidate = output ? `${output} ${sentence}` : sentence;
33
+ if (candidate.length > maxChars) {
34
+ break;
35
+ }
36
+ output = candidate;
37
+ }
38
+ return output || cleaned.slice(0, maxChars);
39
+ }
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ export const commandToolInputSchema = z.object({
3
+ path: z.string().min(1).default("."),
4
+ extraArgs: z.array(z.string().min(1)).max(50).default([]),
5
+ timeoutMs: z.number().int().positive().max(600_000).optional()
6
+ });
7
+ export const commandToolInputJsonSchema = {
8
+ type: "object",
9
+ properties: {
10
+ path: {
11
+ type: "string",
12
+ description: "Terraform project path to scan.",
13
+ default: "."
14
+ },
15
+ extraArgs: {
16
+ type: "array",
17
+ items: {
18
+ type: "string"
19
+ },
20
+ description: "Extra CLI arguments.",
21
+ default: []
22
+ },
23
+ timeoutMs: {
24
+ type: "integer",
25
+ description: "Optional command timeout in milliseconds.",
26
+ minimum: 1,
27
+ maximum: 600000
28
+ }
29
+ },
30
+ additionalProperties: false
31
+ };
32
+ const installHints = {
33
+ tflint: "Install tflint from https://github.com/terraform-linters/tflint and ensure `tflint` is on PATH.",
34
+ checkov: "Install checkov with `pip install checkov` and ensure `checkov` is on PATH.",
35
+ trivy: "Install trivy from https://github.com/aquasecurity/trivy and ensure `trivy` is on PATH.",
36
+ kics: "Install kics from https://github.com/Checkmarx/kics and ensure `kics` is on PATH.",
37
+ infracost: "Install infracost from https://www.infracost.io/docs/ and ensure `infracost` is on PATH."
38
+ };
39
+ export function missingCliMessage(command) {
40
+ return [`Required CLI not found: ${command}`, installHints[command]].join("\n");
41
+ }
42
+ export function formatCommandExecution(result) {
43
+ const lines = [
44
+ `Command: ${result.command} ${result.args.join(" ")}`.trim(),
45
+ `Working directory: ${result.cwd}`,
46
+ `Exit code: ${result.exitCode}`,
47
+ `Timed out: ${result.timedOut ? "yes" : "no"}`,
48
+ `Output truncated: ${result.outputTruncated ? "yes" : "no"}`
49
+ ];
50
+ if (result.stdout) {
51
+ lines.push("", "stdout:", result.stdout);
52
+ }
53
+ if (result.stderr) {
54
+ lines.push("", "stderr:", result.stderr);
55
+ }
56
+ if (!result.stdout && !result.stderr) {
57
+ lines.push("", "No command output was produced.");
58
+ }
59
+ return lines.join("\n");
60
+ }
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { fetchText } from "../lib/http.js";
3
+ import { keywordSnippet, toPlainText } from "../lib/text.js";
4
+ const providerSources = {
5
+ azure: {
6
+ url: "https://learn.microsoft.com/azure/developer/terraform/overview",
7
+ practices: [
8
+ "Use managed identity or workload identity instead of static credentials.",
9
+ "Keep state in Azure Storage with blob versioning and lease locking.",
10
+ "Model least-privilege RBAC for service principals and managed identities.",
11
+ "Prefer provider aliases for multi-subscription or multi-tenant deployments.",
12
+ "Use consistent resource tags for ownership, environment, and cost center.",
13
+ "Use Azure Verified Modules where possible for baseline guardrails."
14
+ ]
15
+ },
16
+ aws: {
17
+ url: "https://docs.aws.amazon.com/prescriptive-guidance/latest/terraform-aws-provider-best-practices/introduction.html",
18
+ practices: [
19
+ "Use dedicated IAM roles for Terraform runs and avoid long-lived keys.",
20
+ "Separate state by account/environment with DynamoDB locking for S3 state.",
21
+ "Pin provider and module versions for reproducible plans.",
22
+ "Adopt tagging standards for cost allocation and inventory automation.",
23
+ "Use data sources carefully and avoid hidden cross-account dependencies.",
24
+ "Enforce security controls through policy and static analysis in CI."
25
+ ]
26
+ },
27
+ gcp: {
28
+ url: "https://cloud.google.com/docs/terraform/best-practices",
29
+ practices: [
30
+ "Use dedicated service accounts with least privilege and short-lived auth.",
31
+ "Store state in versioned GCS buckets with access controls.",
32
+ "Organize modules around folders/projects to match GCP hierarchy.",
33
+ "Use explicit dependency modeling for APIs and IAM propagation-sensitive resources.",
34
+ "Standardize labels for environment, ownership, and compliance.",
35
+ "Run policy checks and drift detection as part of CI/CD workflows."
36
+ ]
37
+ }
38
+ };
39
+ export const fetchProviderBestPracticesInputSchema = z.object({
40
+ provider: z.enum(["azure", "aws", "gcp"]),
41
+ topic: z.string().min(2).max(120).optional(),
42
+ liveFetch: z.boolean().default(true)
43
+ });
44
+ export const fetchProviderBestPracticesInputJsonSchema = {
45
+ type: "object",
46
+ properties: {
47
+ provider: {
48
+ type: "string",
49
+ enum: ["azure", "aws", "gcp"],
50
+ description: "Cloud provider to retrieve Terraform best practices for."
51
+ },
52
+ topic: {
53
+ type: "string",
54
+ description: "Optional focus area, for example state, IAM, modules, networking, or cost."
55
+ },
56
+ liveFetch: {
57
+ type: "boolean",
58
+ description: "When true, attempts to fetch and summarize the linked provider guidance page.",
59
+ default: true
60
+ }
61
+ },
62
+ required: ["provider"],
63
+ additionalProperties: false
64
+ };
65
+ function filteredPractices(practices, topic) {
66
+ if (!topic) {
67
+ return practices;
68
+ }
69
+ const lowered = topic.toLowerCase();
70
+ const filtered = practices.filter((practice) => practice.toLowerCase().includes(lowered));
71
+ return filtered.length > 0 ? filtered : practices;
72
+ }
73
+ export const fetchProviderBestPracticesTool = {
74
+ name: "fetch_provider_best_practices",
75
+ description: "Fetch Terraform best-practice guidance for Azure, AWS, or GCP from curated checks and optional live provider docs summaries.",
76
+ inputSchema: fetchProviderBestPracticesInputSchema,
77
+ inputSchemaJson: fetchProviderBestPracticesInputJsonSchema,
78
+ run: async (input) => {
79
+ const source = providerSources[input.provider];
80
+ const topic = input.topic?.trim();
81
+ const practices = filteredPractices(source.practices, topic);
82
+ const lines = [
83
+ `Provider: ${input.provider}`,
84
+ `Primary source: ${source.url}`,
85
+ "Curated provider checklist:",
86
+ ...practices.map((practice, index) => `${index + 1}. ${practice}`)
87
+ ];
88
+ if (!input.liveFetch) {
89
+ lines.push("", "Live fetch disabled. Returning curated guidance only.");
90
+ return lines.join("\n");
91
+ }
92
+ try {
93
+ const html = await fetchText(source.url);
94
+ const snippet = keywordSnippet(toPlainText(html), topic ? [topic, input.provider] : [input.provider, "terraform", "state", "security", "module"], 1800);
95
+ lines.push("", "Live excerpt:", snippet || "No matching live excerpt found.");
96
+ }
97
+ catch (error) {
98
+ const reason = error instanceof Error ? error.message : String(error);
99
+ lines.push("", `Live fetch unavailable: ${reason}`);
100
+ }
101
+ return lines.join("\n");
102
+ }
103
+ };
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { fetchText } from "../lib/http.js";
3
+ import { keywordSnippet, toPlainText } from "../lib/text.js";
4
+ const sourceUrl = "https://www.terraform-best-practices.com/";
5
+ const curatedPractices = [
6
+ "Pin Terraform and provider versions to avoid unexpected upgrades.",
7
+ "Keep modules small and focused, with clear inputs and outputs.",
8
+ "Use remote state with locking and encryption enabled.",
9
+ "Adopt a consistent naming convention for resources, variables, and outputs.",
10
+ "Prefer `for_each` over `count` for stable resource addressing.",
11
+ "Use `validation` blocks for variables and preconditions on critical resources.",
12
+ "Separate environments by state/workspace boundaries to reduce blast radius.",
13
+ "Treat plans as review artifacts in CI before apply.",
14
+ "Store secrets in dedicated secret managers instead of plaintext variables.",
15
+ "Use policy-as-code and linting in CI (tflint/checkov/trivy/kics).",
16
+ "Document each module with usage examples and constraints.",
17
+ "Continuously track cost drift with tools such as infracost."
18
+ ];
19
+ export const fetchTerraformBestPracticesInputSchema = z.object({
20
+ topic: z.string().min(2).max(120).optional(),
21
+ liveFetch: z.boolean().default(true)
22
+ });
23
+ export const fetchTerraformBestPracticesInputJsonSchema = {
24
+ type: "object",
25
+ properties: {
26
+ topic: {
27
+ type: "string",
28
+ description: "Optional topic filter such as state, modules, security, or naming."
29
+ },
30
+ liveFetch: {
31
+ type: "boolean",
32
+ description: "When true, attempts to fetch and summarize live content from terraform-best-practices.com.",
33
+ default: true
34
+ }
35
+ },
36
+ additionalProperties: false
37
+ };
38
+ function filterPractices(topic) {
39
+ if (!topic) {
40
+ return curatedPractices;
41
+ }
42
+ const loweredTopic = topic.toLowerCase();
43
+ const filtered = curatedPractices.filter((practice) => practice.toLowerCase().includes(loweredTopic));
44
+ return filtered.length > 0 ? filtered : curatedPractices;
45
+ }
46
+ export const fetchTerraformBestPracticesTool = {
47
+ name: "fetch_terraform_best_practices",
48
+ description: "Fetch Terraform best-practice guidance from curated checks and optional live summaries from terraform-best-practices.com.",
49
+ inputSchema: fetchTerraformBestPracticesInputSchema,
50
+ inputSchemaJson: fetchTerraformBestPracticesInputJsonSchema,
51
+ run: async (input) => {
52
+ const topic = input.topic?.trim();
53
+ const practices = filterPractices(topic);
54
+ const lines = [
55
+ `Source: ${sourceUrl}`,
56
+ "Curated Terraform best-practice checklist:",
57
+ ...practices.map((practice, index) => `${index + 1}. ${practice}`)
58
+ ];
59
+ if (!input.liveFetch) {
60
+ lines.push("", "Live fetch disabled. Returning curated guidance only.");
61
+ return lines.join("\n");
62
+ }
63
+ try {
64
+ const html = await fetchText(sourceUrl);
65
+ const text = toPlainText(html);
66
+ const snippet = keywordSnippet(text, topic
67
+ ? [topic]
68
+ : ["state", "module", "provider", "security", "variable", "output", "version"], 1800);
69
+ lines.push("", "Live excerpt:", snippet || "No matching live excerpt found.");
70
+ }
71
+ catch (error) {
72
+ const reason = error instanceof Error ? error.message : String(error);
73
+ lines.push("", `Live fetch unavailable: ${reason}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+ };
@@ -0,0 +1,112 @@
1
+ import { z } from "zod";
2
+ import { fetchText } from "../lib/http.js";
3
+ import { keywordSnippet, toPlainText } from "../lib/text.js";
4
+ const defaultRegistryUrl = "https://registry.terraform.io/";
5
+ const registryPractices = [
6
+ "Pin provider and module versions instead of using unbounded constraints.",
7
+ "Prefer well-maintained modules with clear versioning and examples.",
8
+ "Read provider resource docs for force-recreate fields and lifecycle behavior.",
9
+ "Use module inputs/outputs intentionally and avoid overexposing internals.",
10
+ "Validate required providers and Terraform versions in every module.",
11
+ "Track breaking changes across provider major versions before upgrades."
12
+ ];
13
+ export const fetchTerraformRegistryGuidanceInputSchema = z.object({
14
+ provider: z.string().min(1).max(120).optional(),
15
+ resource: z.string().min(1).max(120).optional(),
16
+ module: z.string().min(1).max(240).optional(),
17
+ topic: z.string().min(2).max(120).optional(),
18
+ liveFetch: z.boolean().default(true)
19
+ });
20
+ export const fetchTerraformRegistryGuidanceInputJsonSchema = {
21
+ type: "object",
22
+ properties: {
23
+ provider: {
24
+ type: "string",
25
+ description: "Optional provider name, for example aws, azurerm, or google."
26
+ },
27
+ resource: {
28
+ type: "string",
29
+ description: "Optional resource type used with provider, for example s3_bucket or resource_group."
30
+ },
31
+ module: {
32
+ type: "string",
33
+ description: "Optional module path in the form namespace/name/provider."
34
+ },
35
+ topic: {
36
+ type: "string",
37
+ description: "Optional topic filter for the summary output."
38
+ },
39
+ liveFetch: {
40
+ type: "boolean",
41
+ description: "When true, fetches and summarizes selected Terraform Registry pages.",
42
+ default: true
43
+ }
44
+ },
45
+ additionalProperties: false
46
+ };
47
+ function selectPractices(topic) {
48
+ if (!topic) {
49
+ return registryPractices;
50
+ }
51
+ const lowered = topic.toLowerCase();
52
+ const filtered = registryPractices.filter((item) => item.toLowerCase().includes(lowered));
53
+ return filtered.length > 0 ? filtered : registryPractices;
54
+ }
55
+ function buildRegistryUrls(input) {
56
+ const urls = [];
57
+ const modulePath = input.module?.trim();
58
+ if (modulePath) {
59
+ urls.push(`https://registry.terraform.io/modules/${encodeURI(modulePath)}`);
60
+ }
61
+ const provider = input.provider?.trim();
62
+ const resource = input.resource?.trim();
63
+ if (provider && resource) {
64
+ urls.push(`https://registry.terraform.io/providers/hashicorp/${encodeURIComponent(provider)}/latest/docs/resources/${encodeURIComponent(resource)}`);
65
+ }
66
+ if (urls.length === 0) {
67
+ urls.push(defaultRegistryUrl);
68
+ }
69
+ return urls;
70
+ }
71
+ export const fetchTerraformRegistryGuidanceTool = {
72
+ name: "fetch_terraform_registry_guidance",
73
+ description: "Fetch Terraform Registry best-practice guidance with optional provider/resource/module context.",
74
+ inputSchema: fetchTerraformRegistryGuidanceInputSchema,
75
+ inputSchemaJson: fetchTerraformRegistryGuidanceInputJsonSchema,
76
+ run: async (input) => {
77
+ const topic = input.topic?.trim();
78
+ const practices = selectPractices(topic);
79
+ const urls = buildRegistryUrls(input);
80
+ const lines = [
81
+ `Registry source: ${defaultRegistryUrl}`,
82
+ "Curated Terraform Registry checklist:",
83
+ ...practices.map((practice, index) => `${index + 1}. ${practice}`)
84
+ ];
85
+ if (!input.liveFetch) {
86
+ lines.push("", "Live fetch disabled. Returning curated guidance only.");
87
+ return lines.join("\n");
88
+ }
89
+ const keywords = [
90
+ ...(topic ? [topic] : []),
91
+ ...(input.provider ? [input.provider] : []),
92
+ ...(input.resource ? [input.resource] : []),
93
+ ...(input.module ? [input.module] : []),
94
+ "terraform",
95
+ "version",
96
+ "provider",
97
+ "module"
98
+ ];
99
+ for (const url of urls) {
100
+ try {
101
+ const html = await fetchText(url);
102
+ const snippet = keywordSnippet(toPlainText(html), keywords, 1400);
103
+ lines.push("", `Live excerpt (${url}):`, snippet || "No matching live excerpt found.");
104
+ }
105
+ catch (error) {
106
+ const reason = error instanceof Error ? error.message : String(error);
107
+ lines.push("", `Live fetch unavailable for ${url}: ${reason}`);
108
+ }
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+ };
@@ -0,0 +1,18 @@
1
+ import { fetchProviderBestPracticesTool } from "./fetch_provider_best_practices.js";
2
+ import { fetchTerraformBestPracticesTool } from "./fetch_terraform_best_practices.js";
3
+ import { fetchTerraformRegistryGuidanceTool } from "./fetch_terraform_registry_guidance.js";
4
+ import { runCheckovTool } from "./run_checkov.js";
5
+ import { runInfracostTool } from "./run_infracost.js";
6
+ import { runKicsTool } from "./run_kics.js";
7
+ import { runTflintTool } from "./run_tflint.js";
8
+ import { runTrivyTool } from "./run_trivy.js";
9
+ export const allTools = [
10
+ runTflintTool,
11
+ runCheckovTool,
12
+ runTrivyTool,
13
+ runKicsTool,
14
+ runInfracostTool,
15
+ fetchTerraformBestPracticesTool,
16
+ fetchProviderBestPracticesTool,
17
+ fetchTerraformRegistryGuidanceTool
18
+ ];
@@ -0,0 +1,23 @@
1
+ import { CommandNotFoundError, runCommand } from "../lib/exec.js";
2
+ import { commandToolInputJsonSchema, commandToolInputSchema, formatCommandExecution, missingCliMessage } from "./common.js";
3
+ export const runCheckovInputSchema = commandToolInputSchema;
4
+ export const runCheckovTool = {
5
+ name: "run_checkov",
6
+ description: "Run checkov over a Terraform directory.",
7
+ inputSchema: runCheckovInputSchema,
8
+ inputSchemaJson: commandToolInputJsonSchema,
9
+ run: async (input) => {
10
+ try {
11
+ const result = await runCommand("checkov", ["-d", input.path, ...input.extraArgs], {
12
+ timeoutMs: input.timeoutMs
13
+ });
14
+ return formatCommandExecution(result);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof CommandNotFoundError) {
18
+ return missingCliMessage("checkov");
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,23 @@
1
+ import { CommandNotFoundError, runCommand } from "../lib/exec.js";
2
+ import { commandToolInputJsonSchema, commandToolInputSchema, formatCommandExecution, missingCliMessage } from "./common.js";
3
+ export const runInfracostInputSchema = commandToolInputSchema;
4
+ export const runInfracostTool = {
5
+ name: "run_infracost",
6
+ description: "Run infracost breakdown for a Terraform directory.",
7
+ inputSchema: runInfracostInputSchema,
8
+ inputSchemaJson: commandToolInputJsonSchema,
9
+ run: async (input) => {
10
+ try {
11
+ const result = await runCommand("infracost", ["breakdown", "--path", input.path, "--format", "json", ...input.extraArgs], {
12
+ timeoutMs: input.timeoutMs
13
+ });
14
+ return formatCommandExecution(result);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof CommandNotFoundError) {
18
+ return missingCliMessage("infracost");
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,23 @@
1
+ import { CommandNotFoundError, runCommand } from "../lib/exec.js";
2
+ import { commandToolInputJsonSchema, commandToolInputSchema, formatCommandExecution, missingCliMessage } from "./common.js";
3
+ export const runKicsInputSchema = commandToolInputSchema;
4
+ export const runKicsTool = {
5
+ name: "run_kics",
6
+ description: "Run kics IaC scanning against Terraform code.",
7
+ inputSchema: runKicsInputSchema,
8
+ inputSchemaJson: commandToolInputJsonSchema,
9
+ run: async (input) => {
10
+ try {
11
+ const result = await runCommand("kics", ["scan", "-p", input.path, ...input.extraArgs], {
12
+ timeoutMs: input.timeoutMs
13
+ });
14
+ return formatCommandExecution(result);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof CommandNotFoundError) {
18
+ return missingCliMessage("kics");
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,24 @@
1
+ import { CommandNotFoundError, runCommand } from "../lib/exec.js";
2
+ import { commandToolInputJsonSchema, commandToolInputSchema, formatCommandExecution, missingCliMessage } from "./common.js";
3
+ export const runTflintInputSchema = commandToolInputSchema;
4
+ export const runTflintTool = {
5
+ name: "run_tflint",
6
+ description: "Run tflint against a Terraform project directory.",
7
+ inputSchema: runTflintInputSchema,
8
+ inputSchemaJson: commandToolInputJsonSchema,
9
+ run: async (input) => {
10
+ try {
11
+ const result = await runCommand("tflint", [...input.extraArgs], {
12
+ cwd: input.path,
13
+ timeoutMs: input.timeoutMs
14
+ });
15
+ return formatCommandExecution(result);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof CommandNotFoundError) {
19
+ return missingCliMessage("tflint");
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ };
@@ -0,0 +1,23 @@
1
+ import { CommandNotFoundError, runCommand } from "../lib/exec.js";
2
+ import { commandToolInputJsonSchema, commandToolInputSchema, formatCommandExecution, missingCliMessage } from "./common.js";
3
+ export const runTrivyInputSchema = commandToolInputSchema;
4
+ export const runTrivyTool = {
5
+ name: "run_trivy",
6
+ description: "Run trivy config scanning against Terraform code.",
7
+ inputSchema: runTrivyInputSchema,
8
+ inputSchemaJson: commandToolInputJsonSchema,
9
+ run: async (input) => {
10
+ try {
11
+ const result = await runCommand("trivy", ["config", input.path, ...input.extraArgs], {
12
+ timeoutMs: input.timeoutMs
13
+ });
14
+ return formatCommandExecution(result);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof CommandNotFoundError) {
18
+ return missingCliMessage("trivy");
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ };
@@ -0,0 +1 @@
1
+ export {};
package/mcp.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.DownAtTheBottomOfTheMoleHole/terraform-best-practices",
4
+ "description": "MCP server for Terraform best-practice guidance, linting, security scanning, and cost insights",
5
+ "repository": {
6
+ "url": "https://github.com/DownAtTheBottomOfTheMoleHole/terraform-best-practices-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "@downatthebottomofthemolehole/terraform-best-practices-mcp-server",
14
+ "version": "0.1.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ }
18
+ }
19
+ ]
20
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@downatthebottomofthemolehole/terraform-best-practices-mcp-server",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.DownAtTheBottomOfTheMoleHole/terraform-best-practices",
5
+ "description": "MCP server for Terraform cost, lint, security, and cloud best-practice guidance.",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "bin": {
10
+ "terraform-best-practices-mcp-server": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist/**/*",
14
+ "mcp.json",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "mcp": {
19
+ "name": "terraform-best-practices",
20
+ "description": "Terraform best-practice, security, lint, and cost analysis via MCP"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "terraform",
25
+ "best-practices",
26
+ "infracost",
27
+ "tflint",
28
+ "checkov",
29
+ "trivy",
30
+ "kics",
31
+ "azure",
32
+ "aws",
33
+ "gcp"
34
+ ],
35
+ "author": "Carl Dawson",
36
+ "repository": "https://github.com/DownAtTheBottomOfTheMoleHole/terraform-best-practices-mcp",
37
+ "homepage": "https://github.com/DownAtTheBottomOfTheMoleHole/terraform-best-practices-mcp",
38
+ "bugs": {
39
+ "url": "https://github.com/DownAtTheBottomOfTheMoleHole/terraform-best-practices-mcp/issues"
40
+ },
41
+ "engines": {
42
+ "node": ">=24.0.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "dev": "tsx src/index.ts",
47
+ "start": "node dist/index.js",
48
+ "check": "tsc --noEmit -p tsconfig.json",
49
+ "lint": "eslint --ext .ts src tests",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest"
52
+ },
53
+ "dependencies": {
54
+ "@modelcontextprotocol/sdk": "1.27.1",
55
+ "zod": "^3.23.8"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "25.3.5",
59
+ "@typescript-eslint/eslint-plugin": "8.56.1",
60
+ "@typescript-eslint/parser": "8.56.1",
61
+ "eslint": "8.57.1",
62
+ "tsx": "4.21.0",
63
+ "typescript": "5.9.3",
64
+ "vitest": "4.0.18"
65
+ },
66
+ "license": "MIT"
67
+ }