mcp-server-terraform 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/README.md +47 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/terraform.d.ts +12 -0
- package/dist/terraform.d.ts.map +1 -0
- package/dist/terraform.js +37 -0
- package/dist/terraform.js.map +1 -0
- package/dist/tools/apply.d.ts +3 -0
- package/dist/tools/apply.d.ts.map +1 -0
- package/dist/tools/apply.js +30 -0
- package/dist/tools/apply.js.map +1 -0
- package/dist/tools/destroy.d.ts +3 -0
- package/dist/tools/destroy.d.ts.map +1 -0
- package/dist/tools/destroy.js +30 -0
- package/dist/tools/destroy.js.map +1 -0
- package/dist/tools/fmt.d.ts +3 -0
- package/dist/tools/fmt.d.ts.map +1 -0
- package/dist/tools/fmt.js +21 -0
- package/dist/tools/fmt.js.map +1 -0
- package/dist/tools/import.d.ts +3 -0
- package/dist/tools/import.d.ts.map +1 -0
- package/dist/tools/import.js +16 -0
- package/dist/tools/import.js.map +1 -0
- package/dist/tools/init.d.ts +3 -0
- package/dist/tools/init.d.ts.map +1 -0
- package/dist/tools/init.js +27 -0
- package/dist/tools/init.js.map +1 -0
- package/dist/tools/output.d.ts +3 -0
- package/dist/tools/output.d.ts.map +1 -0
- package/dist/tools/output.js +21 -0
- package/dist/tools/output.js.map +1 -0
- package/dist/tools/plan.d.ts +3 -0
- package/dist/tools/plan.d.ts.map +1 -0
- package/dist/tools/plan.js +30 -0
- package/dist/tools/plan.js.map +1 -0
- package/dist/tools/state-list.d.ts +3 -0
- package/dist/tools/state-list.d.ts.map +1 -0
- package/dist/tools/state-list.js +18 -0
- package/dist/tools/state-list.js.map +1 -0
- package/dist/tools/state-show.d.ts +3 -0
- package/dist/tools/state-show.d.ts.map +1 -0
- package/dist/tools/state-show.js +15 -0
- package/dist/tools/state-show.js.map +1 -0
- package/dist/tools/validate.d.ts +3 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +14 -0
- package/dist/tools/validate.js.map +1 -0
- package/package.json +67 -0
- package/src/index.ts +40 -0
- package/src/terraform.ts +49 -0
- package/src/tools/apply.ts +33 -0
- package/src/tools/destroy.ts +33 -0
- package/src/tools/fmt.ts +25 -0
- package/src/tools/import.ts +25 -0
- package/src/tools/init.ts +31 -0
- package/src/tools/output.ts +25 -0
- package/src/tools/plan.ts +33 -0
- package/src/tools/state-list.ts +23 -0
- package/src/tools/state-show.ts +21 -0
- package/src/tools/validate.ts +20 -0
- package/tests/terraform.test.ts +68 -0
- package/tests/tools.test.ts +160 -0
- package/tsconfig.json +23 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerPlanTool } from "./tools/plan.js";
|
|
5
|
+
import { registerApplyTool } from "./tools/apply.js";
|
|
6
|
+
import { registerDestroyTool } from "./tools/destroy.js";
|
|
7
|
+
import { registerInitTool } from "./tools/init.js";
|
|
8
|
+
import { registerValidateTool } from "./tools/validate.js";
|
|
9
|
+
import { registerStateListTool } from "./tools/state-list.js";
|
|
10
|
+
import { registerStateShowTool } from "./tools/state-show.js";
|
|
11
|
+
import { registerFmtTool } from "./tools/fmt.js";
|
|
12
|
+
import { registerOutputTool } from "./tools/output.js";
|
|
13
|
+
import { registerImportTool } from "./tools/import.js";
|
|
14
|
+
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: "terraform-mcp-server",
|
|
17
|
+
version: "0.1.0",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
registerPlanTool(server);
|
|
21
|
+
registerApplyTool(server);
|
|
22
|
+
registerDestroyTool(server);
|
|
23
|
+
registerInitTool(server);
|
|
24
|
+
registerValidateTool(server);
|
|
25
|
+
registerStateListTool(server);
|
|
26
|
+
registerStateShowTool(server);
|
|
27
|
+
registerFmtTool(server);
|
|
28
|
+
registerOutputTool(server);
|
|
29
|
+
registerImportTool(server);
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const transport = new StdioServerTransport();
|
|
33
|
+
await server.connect(transport);
|
|
34
|
+
console.error("Terraform MCP Server running on stdio");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
console.error("Fatal error:", error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
package/src/terraform.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFile, ExecFileOptions } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface TerraformResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function runTerraform(
|
|
13
|
+
args: string[],
|
|
14
|
+
options: {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
timeout?: number;
|
|
18
|
+
} = {}
|
|
19
|
+
): Promise<TerraformResult> {
|
|
20
|
+
const { cwd = process.cwd(), env, timeout = 120000 } = options;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await execFileAsync("terraform", args, {
|
|
24
|
+
cwd,
|
|
25
|
+
timeout,
|
|
26
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
27
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
stdout: result.stdout,
|
|
31
|
+
stderr: result.stderr,
|
|
32
|
+
exitCode: 0,
|
|
33
|
+
};
|
|
34
|
+
} catch (error: any) {
|
|
35
|
+
return {
|
|
36
|
+
stdout: error.stdout || "",
|
|
37
|
+
stderr: error.stderr || error.message,
|
|
38
|
+
exitCode: error.code ?? 1,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatResult(result: TerraformResult): string {
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
if (result.stdout.trim()) parts.push(result.stdout.trim());
|
|
46
|
+
if (result.stderr.trim()) parts.push(`STDERR:\n${result.stderr.trim()}`);
|
|
47
|
+
if (result.exitCode !== 0) parts.push(`Exit code: ${result.exitCode}`);
|
|
48
|
+
return parts.join("\n\n") || "No output.";
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerApplyTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_apply",
|
|
8
|
+
"Apply Terraform changes to create, update, or destroy infrastructure resources.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
auto_approve: z.boolean().optional().describe("Skip interactive approval (default: false — requires confirmation)"),
|
|
12
|
+
target: z.string().optional().describe("Target specific resource"),
|
|
13
|
+
var_file: z.string().optional().describe("Path to variables file"),
|
|
14
|
+
vars: z.record(z.string()).optional().describe("Variable values to pass"),
|
|
15
|
+
},
|
|
16
|
+
async ({ directory, auto_approve, target, var_file, vars }) => {
|
|
17
|
+
const args = ["apply", "-no-color"];
|
|
18
|
+
if (auto_approve) args.push("-auto-approve");
|
|
19
|
+
if (target) args.push("-target", target);
|
|
20
|
+
if (var_file) args.push("-var-file", var_file);
|
|
21
|
+
if (vars) {
|
|
22
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
23
|
+
args.push("-var", `${key}=${value}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
29
|
+
isError: result.exitCode !== 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerDestroyTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_destroy",
|
|
8
|
+
"Destroy all Terraform-managed infrastructure. Use with caution — this removes real resources.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
auto_approve: z.boolean().optional().describe("Skip interactive approval (default: false)"),
|
|
12
|
+
target: z.string().optional().describe("Target specific resource to destroy"),
|
|
13
|
+
var_file: z.string().optional().describe("Path to variables file"),
|
|
14
|
+
vars: z.record(z.string()).optional().describe("Variable values to pass"),
|
|
15
|
+
},
|
|
16
|
+
async ({ directory, auto_approve, target, var_file, vars }) => {
|
|
17
|
+
const args = ["destroy", "-no-color"];
|
|
18
|
+
if (auto_approve) args.push("-auto-approve");
|
|
19
|
+
if (target) args.push("-target", target);
|
|
20
|
+
if (var_file) args.push("-var-file", var_file);
|
|
21
|
+
if (vars) {
|
|
22
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
23
|
+
args.push("-var", `${key}=${value}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
29
|
+
isError: result.exitCode !== 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
}
|
package/src/tools/fmt.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerFmtTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_fmt",
|
|
8
|
+
"Reformat Terraform configuration files to canonical style (consistent indentation and alignment).",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
check: z.boolean().optional().describe("Check if files are formatted (don't modify, just report)"),
|
|
12
|
+
recursive: z.boolean().optional().describe("Process files in subdirectories"),
|
|
13
|
+
},
|
|
14
|
+
async ({ directory, check, recursive }) => {
|
|
15
|
+
const args = ["fmt", "-no-color"];
|
|
16
|
+
if (check) args.push("-check");
|
|
17
|
+
if (recursive) args.push("-recursive");
|
|
18
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
21
|
+
isError: result.exitCode !== 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerImportTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_import",
|
|
8
|
+
"Import an existing infrastructure resource into Terraform state. Maps a real-world resource to a Terraform resource address.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
resource_address: z.string().describe("Terraform resource address (e.g., aws_instance.web)"),
|
|
12
|
+
resource_id: z.string().describe("ID of the existing resource to import (e.g., i-1234567890abcdef0)"),
|
|
13
|
+
},
|
|
14
|
+
async ({ directory, resource_address, resource_id }) => {
|
|
15
|
+
const result = await runTerraform(
|
|
16
|
+
["import", "-no-color", resource_address, resource_id],
|
|
17
|
+
{ cwd: directory }
|
|
18
|
+
);
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
21
|
+
isError: result.exitCode !== 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerInitTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_init",
|
|
8
|
+
"Initialize a Terraform working directory. Downloads providers, modules, and configures the backend.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
upgrade: z.boolean().optional().describe("Upgrade providers and modules to latest compatible versions"),
|
|
12
|
+
reconfigure: z.boolean().optional().describe("Reconfigure the backend (discard previous state)"),
|
|
13
|
+
backend_config: z.record(z.string()).optional().describe("Backend configuration values"),
|
|
14
|
+
},
|
|
15
|
+
async ({ directory, upgrade, reconfigure, backend_config }) => {
|
|
16
|
+
const args = ["init", "-no-color"];
|
|
17
|
+
if (upgrade) args.push("-upgrade");
|
|
18
|
+
if (reconfigure) args.push("-reconfigure");
|
|
19
|
+
if (backend_config) {
|
|
20
|
+
for (const [key, value] of Object.entries(backend_config)) {
|
|
21
|
+
args.push("-backend-config", `${key}=${value}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
27
|
+
isError: result.exitCode !== 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerOutputTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_output",
|
|
8
|
+
"Show output values from the Terraform state. Outputs are defined in output blocks and often contain important resource attributes.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
name: z.string().optional().describe("Show a specific output by name"),
|
|
12
|
+
json: z.boolean().optional().describe("Output in JSON format"),
|
|
13
|
+
},
|
|
14
|
+
async ({ directory, name, json }) => {
|
|
15
|
+
const args = ["output", "-no-color"];
|
|
16
|
+
if (json) args.push("-json");
|
|
17
|
+
if (name) args.push(name);
|
|
18
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
21
|
+
isError: result.exitCode !== 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerPlanTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_plan",
|
|
8
|
+
"Preview changes Terraform will make to infrastructure without applying them. Shows added, changed, and destroyed resources.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
destroy: z.boolean().optional().describe("If true, show destroy plan instead of normal plan"),
|
|
12
|
+
target: z.string().optional().describe("Target specific resource (e.g., aws_instance.web)"),
|
|
13
|
+
var_file: z.string().optional().describe("Path to variables file"),
|
|
14
|
+
vars: z.record(z.string()).optional().describe("Variable values to pass (e.g., region=us-east-1)"),
|
|
15
|
+
},
|
|
16
|
+
async ({ directory, destroy, target, var_file, vars }) => {
|
|
17
|
+
const args = ["plan", "-no-color"];
|
|
18
|
+
if (destroy) args.push("-destroy");
|
|
19
|
+
if (target) args.push("-target", target);
|
|
20
|
+
if (var_file) args.push("-var-file", var_file);
|
|
21
|
+
if (vars) {
|
|
22
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
23
|
+
args.push("-var", `${key}=${value}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
29
|
+
isError: result.exitCode !== 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerStateListTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_state_list",
|
|
8
|
+
"List all resources tracked in the Terraform state file.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
id_pattern: z.string().optional().describe("Filter resources by ID pattern (supports glob)"),
|
|
12
|
+
},
|
|
13
|
+
async ({ directory, id_pattern }) => {
|
|
14
|
+
const args = ["state", "list", "-no-color"];
|
|
15
|
+
if (id_pattern) args.push(id_pattern);
|
|
16
|
+
const result = await runTerraform(args, { cwd: directory });
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
19
|
+
isError: result.exitCode !== 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerStateShowTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_state_show",
|
|
8
|
+
"Show detailed attributes of a specific resource in the Terraform state.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
resource_id: z.string().describe("Resource ID in state (e.g., aws_instance.web)"),
|
|
12
|
+
},
|
|
13
|
+
async ({ directory, resource_id }) => {
|
|
14
|
+
const result = await runTerraform(["state", "show", "-no-color", resource_id], { cwd: directory });
|
|
15
|
+
return {
|
|
16
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
17
|
+
isError: result.exitCode !== 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runTerraform, formatResult } from "../terraform.js";
|
|
4
|
+
|
|
5
|
+
export function registerValidateTool(server: McpServer) {
|
|
6
|
+
server.tool(
|
|
7
|
+
"terraform_validate",
|
|
8
|
+
"Validate Terraform configuration files for syntax and internal consistency without contacting any remote services.",
|
|
9
|
+
{
|
|
10
|
+
directory: z.string().describe("Working directory containing Terraform files"),
|
|
11
|
+
},
|
|
12
|
+
async ({ directory }) => {
|
|
13
|
+
const result = await runTerraform(["validate", "-no-color"], { cwd: directory });
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
16
|
+
isError: result.exitCode !== 0,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { runTerraform, formatResult } from "../src/terraform.js";
|
|
3
|
+
|
|
4
|
+
// Mock child_process
|
|
5
|
+
vi.mock("node:child_process", () => ({
|
|
6
|
+
execFile: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
describe("runTerraform", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("calls terraform with correct args", async () => {
|
|
17
|
+
vi.mocked(execFile).mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
|
18
|
+
if (typeof cb === "function") {
|
|
19
|
+
cb(null, { stdout: "ok", stderr: "" });
|
|
20
|
+
}
|
|
21
|
+
return {} as any;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await runTerraform(["plan", "-no-color"]);
|
|
25
|
+
expect(result.exitCode).toBe(0);
|
|
26
|
+
expect(result.stdout).toBe("ok");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns exit code on error", async () => {
|
|
30
|
+
vi.mocked(execFile).mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
|
31
|
+
if (typeof cb === "function") {
|
|
32
|
+
const err = new Error("terraform failed") as any;
|
|
33
|
+
err.stdout = "Error: Invalid";
|
|
34
|
+
err.stderr = "exit code 1";
|
|
35
|
+
err.code = 1;
|
|
36
|
+
cb(err);
|
|
37
|
+
}
|
|
38
|
+
return {} as any;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await runTerraform(["plan"]);
|
|
42
|
+
expect(result.exitCode).toBe(1);
|
|
43
|
+
expect(result.stderr).toContain("exit code 1");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("formatResult", () => {
|
|
48
|
+
it("returns stdout when available", () => {
|
|
49
|
+
const result = formatResult({ stdout: "Apply complete!", stderr: "", exitCode: 0 });
|
|
50
|
+
expect(result).toBe("Apply complete!");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("includes stderr when present", () => {
|
|
54
|
+
const result = formatResult({ stdout: "", stderr: "Warning: deprecated", exitCode: 0 });
|
|
55
|
+
expect(result).toContain("STDERR:");
|
|
56
|
+
expect(result).toContain("Warning: deprecated");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("includes exit code when non-zero", () => {
|
|
60
|
+
const result = formatResult({ stdout: "", stderr: "", exitCode: 1 });
|
|
61
|
+
expect(result).toContain("Exit code: 1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns 'No output.' for empty result", () => {
|
|
65
|
+
const result = formatResult({ stdout: "", stderr: "", exitCode: 0 });
|
|
66
|
+
expect(result).toBe("No output.");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { registerPlanTool } from "../src/tools/plan.js";
|
|
4
|
+
import { registerApplyTool } from "../src/tools/apply.js";
|
|
5
|
+
import { registerDestroyTool } from "../src/tools/destroy.js";
|
|
6
|
+
import { registerInitTool } from "../src/tools/init.js";
|
|
7
|
+
import { registerValidateTool } from "../src/tools/validate.js";
|
|
8
|
+
import { registerStateListTool } from "../src/tools/state-list.js";
|
|
9
|
+
import { registerStateShowTool } from "../src/tools/state-show.js";
|
|
10
|
+
import { registerFmtTool } from "../src/tools/fmt.js";
|
|
11
|
+
import { registerOutputTool } from "../src/tools/output.js";
|
|
12
|
+
import { registerImportTool } from "../src/tools/import.js";
|
|
13
|
+
|
|
14
|
+
// Mock terraform module
|
|
15
|
+
vi.mock("../src/terraform.js", () => {
|
|
16
|
+
const mockResult = {
|
|
17
|
+
stdout: "Terraform has been successfully initialized.",
|
|
18
|
+
stderr: "",
|
|
19
|
+
exitCode: 0,
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
runTerraform: vi.fn().mockResolvedValue(mockResult),
|
|
23
|
+
formatResult: vi.fn().mockImplementation((r) => r.stdout || r.stderr || "No output."),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function createMockServer(): McpServer {
|
|
28
|
+
return new McpServer({ name: "test-terraform", version: "0.0.1" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("Tool Registration", () => {
|
|
32
|
+
it("registers all 10 tools", () => {
|
|
33
|
+
const server = createMockServer();
|
|
34
|
+
const toolNames: string[] = [];
|
|
35
|
+
|
|
36
|
+
// Intercept tool registration
|
|
37
|
+
const originalTool = server.tool.bind(server);
|
|
38
|
+
server.tool = vi.fn().mockImplementation((name: string, ...args: any[]) => {
|
|
39
|
+
toolNames.push(name);
|
|
40
|
+
return originalTool(name, ...args);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
registerPlanTool(server);
|
|
44
|
+
registerApplyTool(server);
|
|
45
|
+
registerDestroyTool(server);
|
|
46
|
+
registerInitTool(server);
|
|
47
|
+
registerValidateTool(server);
|
|
48
|
+
registerStateListTool(server);
|
|
49
|
+
registerStateShowTool(server);
|
|
50
|
+
registerFmtTool(server);
|
|
51
|
+
registerOutputTool(server);
|
|
52
|
+
registerImportTool(server);
|
|
53
|
+
|
|
54
|
+
expect(toolNames).toContain("terraform_plan");
|
|
55
|
+
expect(toolNames).toContain("terraform_apply");
|
|
56
|
+
expect(toolNames).toContain("terraform_destroy");
|
|
57
|
+
expect(toolNames).toContain("terraform_init");
|
|
58
|
+
expect(toolNames).toContain("terraform_validate");
|
|
59
|
+
expect(toolNames).toContain("terraform_state_list");
|
|
60
|
+
expect(toolNames).toContain("terraform_state_show");
|
|
61
|
+
expect(toolNames).toContain("terraform_fmt");
|
|
62
|
+
expect(toolNames).toContain("terraform_output");
|
|
63
|
+
expect(toolNames).toContain("terraform_import");
|
|
64
|
+
expect(toolNames).toHaveLength(10);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("Plan Tool", () => {
|
|
69
|
+
it("calls terraform plan with no-color flag", async () => {
|
|
70
|
+
const { runTerraform } = await import("../src/terraform.js");
|
|
71
|
+
const mockRun = vi.mocked(runTerraform);
|
|
72
|
+
mockRun.mockResolvedValueOnce({ stdout: "No changes.", stderr: "", exitCode: 0 });
|
|
73
|
+
|
|
74
|
+
const server = createMockServer();
|
|
75
|
+
registerPlanTool(server);
|
|
76
|
+
|
|
77
|
+
// Access the registered tool handler
|
|
78
|
+
const tools = (server as any)._tools || {};
|
|
79
|
+
// Tool was registered - verify mock was called when invoked
|
|
80
|
+
expect(mockRun).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("includes -destroy flag when destroy option is true", async () => {
|
|
84
|
+
const { runTerraform } = await import("../src/terraform.js");
|
|
85
|
+
const mockRun = vi.mocked(runTerraform);
|
|
86
|
+
mockRun.mockClear();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("Apply Tool", () => {
|
|
91
|
+
it("registers with correct name", () => {
|
|
92
|
+
const server = createMockServer();
|
|
93
|
+
registerApplyTool(server);
|
|
94
|
+
expect(server).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Destroy Tool", () => {
|
|
99
|
+
it("registers with correct name", () => {
|
|
100
|
+
const server = createMockServer();
|
|
101
|
+
registerDestroyTool(server);
|
|
102
|
+
expect(server).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("Init Tool", () => {
|
|
107
|
+
it("registers with correct name", () => {
|
|
108
|
+
const server = createMockServer();
|
|
109
|
+
registerInitTool(server);
|
|
110
|
+
expect(server).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("Validate Tool", () => {
|
|
115
|
+
it("registers with correct name", () => {
|
|
116
|
+
const server = createMockServer();
|
|
117
|
+
registerValidateTool(server);
|
|
118
|
+
expect(server).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("State List Tool", () => {
|
|
123
|
+
it("registers with correct name", () => {
|
|
124
|
+
const server = createMockServer();
|
|
125
|
+
registerStateListTool(server);
|
|
126
|
+
expect(server).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("State Show Tool", () => {
|
|
131
|
+
it("registers with correct name", () => {
|
|
132
|
+
const server = createMockServer();
|
|
133
|
+
registerStateShowTool(server);
|
|
134
|
+
expect(server).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("Fmt Tool", () => {
|
|
139
|
+
it("registers with correct name", () => {
|
|
140
|
+
const server = createMockServer();
|
|
141
|
+
registerFmtTool(server);
|
|
142
|
+
expect(server).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("Output Tool", () => {
|
|
147
|
+
it("registers with correct name", () => {
|
|
148
|
+
const server = createMockServer();
|
|
149
|
+
registerOutputTool(server);
|
|
150
|
+
expect(server).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("Import Tool", () => {
|
|
155
|
+
it("registers with correct name", () => {
|
|
156
|
+
const server = createMockServer();
|
|
157
|
+
registerImportTool(server);
|
|
158
|
+
expect(server).toBeDefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"src/**/*"
|
|
18
|
+
],
|
|
19
|
+
"exclude": [
|
|
20
|
+
"node_modules",
|
|
21
|
+
"dist"
|
|
22
|
+
]
|
|
23
|
+
}
|