terminal-mcp-server

Public

dist / index.js

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { exec } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
// ─── Config ──────────────────────────────────────────────────────────────────
const MAX_OUTPUT_CHARS = 50_000;
const DEFAULT_TIMEOUT_MS = 30_000;
const DANGEROUS_COMMANDS = [
    /rm\s+-rf\s+\/(?:\s|$)/,
    /mkfs/,
    /:\(\)\{.*\}/,
    /dd\s+if=.*of=\/dev\/(sd|hd)/,
    />\s*\/dev\/(sd|hd)/,
    /shutdown/,
    /reboot/,
    /halt/,
    /init\s+0/,
];
function isSafeCommand(cmd) {
    if (process.env.ALLOW_DANGEROUS === "1")
        return { safe: true };
    for (const pattern of DANGEROUS_COMMANDS) {
        if (pattern.test(cmd)) {
            return { safe: false, reason: `Blocked by pattern: ${pattern}` };
        }
    }
    return { safe: true };
}
function truncate(text) {
    if (text.length <= MAX_OUTPUT_CHARS)
        return text;
    const half = MAX_OUTPUT_CHARS / 2;
    return (text.slice(0, half) +
        `\n\n[... ${text.length - MAX_OUTPUT_CHARS} chars truncated ...]\n\n` +
        text.slice(-half));
}
/** Format mtime as "YYYY-MM-DD HH:MM" — precise enough, far shorter than ISO 8601 */
function fmtTime(d) {
    return d.toISOString().slice(0, 16).replace("T", " ");
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
function resolvePath(userPath, cwd) {
    if (userPath.startsWith("~")) {
        return path.resolve(os.homedir(), userPath.slice(userPath.startsWith("~/") ? 2 : 1));
    }
    return path.isAbsolute(userPath) ? userPath : path.resolve(cwd, userPath);
}
// ─── State ────────────────────────────────────────────────────────────────────
function resolveStartingCwd() {
    const envCwd = process.env.TERMINAL_CWD;
    if (envCwd) {
        try {
            if (fs.statSync(envCwd).isDirectory())
                return path.resolve(envCwd);
            console.error(`[terminal-mcp] TERMINAL_CWD is not a directory: ${envCwd}. Falling back to process.cwd().`);
        }
        catch {
            console.error(`[terminal-mcp] TERMINAL_CWD does not exist: ${envCwd}. Falling back to process.cwd().`);
        }
    }
    return process.cwd();
}
let currentWorkingDir = resolveStartingCwd();
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Compact MCP text response */
function ok(data) {
    return { content: [{ type: "text", text: JSON.stringify(data) }] };
}
function err(message) {
    return ok({ error: message });
}
// ─── Server ───────────────────────────────────────────────────────────────────
const server = new McpServer({
    name: "terminal-mcp-server",
    version: "1.0.0",
});
// ─── Tools ────────────────────────────────────────────────────────────────────
server.registerTool("terminal_execute", {
    title: "Execute Shell Command",
    description: "Run a shell command in the current working directory. " +
        "The working directory persists across calls — use terminal_cd to change it. " +
        "Dangerous commands (rm -rf /, mkfs, fork bombs, etc.) are blocked unless ALLOW_DANGEROUS=1.",
    inputSchema: z.object({
        command: z.string().min(1).describe("Shell command to execute"),
        timeout_ms: z
            .number().int().min(1000).max(300_000)
            .default(DEFAULT_TIMEOUT_MS)
            .describe("Timeout in ms (default 30000)"),
        env: z.record(z.string()).optional().describe("Extra env vars for this command"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ command, timeout_ms, env }) => {
    const check = isSafeCommand(command);
    if (!check.safe) {
        return ok({ exit_code: 1, stderr: `Command blocked. ${check.reason}` });
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeout_ms);
    try {
        const { stdout, stderr } = await new Promise((resolve, reject) => {
            exec(command, {
                cwd: currentWorkingDir,
                env: { ...process.env, ...env },
                maxBuffer: 10 * 1024 * 1024,
                signal: controller.signal,
            }, (e, stdout, stderr) => {
                if (e)
                    reject(Object.assign(e, { stdout, stderr }));
                else
                    resolve({ stdout, stderr });
            });
        });
        clearTimeout(timer);
        // Only include stdout/stderr when non-empty
        const result = { exit_code: 0 };
        if (stdout.trim())
            result.stdout = truncate(stdout);
        if (stderr.trim())
            result.stderr = truncate(stderr);
        return ok(result);
    }
    catch (e) {
        clearTimeout(timer);
        const isAbort = e.code === "ABORT_ERR" ||
            e.name === "AbortError";
        if (isAbort) {
            return ok({ exit_code: 124, timed_out: true, stderr: `Killed after ${timeout_ms}ms` });
        }
        const execErr = e;
        const result = { exit_code: execErr.code ?? 1 };
        if (execErr.stdout?.trim())
            result.stdout = truncate(execErr.stdout);
        if (execErr.stderr?.trim())
            result.stderr = truncate(execErr.stderr);
        else
            result.stderr = String(e);
        return ok(result);
    }
});
server.registerTool("terminal_cd", {
    title: "Change Directory",
    description: "Change the working directory for subsequent terminal_execute calls. Supports ~, .., relative and absolute paths.",
    inputSchema: z.object({
        path: z.string().min(1).describe("Target directory path"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ path: targetPath }) => {
    const resolved = resolvePath(targetPath, currentWorkingDir);
    try {
        const stat = fs.statSync(resolved);
        if (!stat.isDirectory())
            throw new Error(`Not a directory: ${resolved}`);
        currentWorkingDir = resolved;
        return ok({ cwd: currentWorkingDir });
    }
    catch (e) {
        return err(`Failed to change directory: ${e.message}`);
    }
});
server.registerTool("terminal_pwd", {
    title: "Print Working Directory",
    description: "Return the current working directory used by terminal_execute.",
    inputSchema: z.object({}),
    annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async () => ok({ cwd: currentWorkingDir }));
server.registerTool("terminal_read_file", {
    title: "Read File",
    description: "Read a file's contents as UTF-8 text, optionally restricted to a line range (1-based, inclusive). " +
        "Defaults to the first 100 lines when no range is given. " +
        "Returns content, total_lines, and size_bytes.",
    inputSchema: z.object({
        path: z.string().min(1).describe("File path (absolute, relative to cwd, or ~)"),
        start_line: z.number().int().min(1).optional().describe("First line to return (default: 1)"),
        end_line: z.number().int().min(1).optional().describe("Last line to return (default: 100 lines from start_line)"),
    }),
    annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ path: filePath, start_line, end_line }) => {
    const resolved = resolvePath(filePath, currentWorkingDir);
    try {
        const stat = fs.statSync(resolved);
        const raw = fs.readFileSync(resolved, "utf8");
        const allLines = raw.split("\n");
        const totalLines = allLines.length;
        const from = (start_line ?? 1) - 1;
        const to = end_line !== undefined ? end_line : from + 100;
        if (from < 0 || from >= totalLines) {
            return err(`start_line ${start_line} out of range (file has ${totalLines} lines)`);
        }
        if (to < from + 1) {
            return err("end_line must be >= start_line");
        }
        const slice = allLines.slice(from, to);
        const result = {
            content: slice.join("\n"),
            total_lines: totalLines,
            size_bytes: stat.size,
        };
        // Only include range info when a partial read was requested
        if (start_line !== undefined || end_line !== undefined) {
            result.range = `${from + 1}-${from + slice.length}`;
        }
        return ok(result);
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_write_file", {
    title: "Write File",
    description: "Write UTF-8 text to a file, creating it (and any parent directories) if needed. " +
        "Set append=true to append instead of overwrite.",
    inputSchema: z.object({
        path: z.string().min(1).describe("File path to write"),
        content: z.string().describe("Content to write"),
        append: z.boolean().default(false).describe("Append instead of overwrite"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ path: filePath, content, append }) => {
    const resolved = resolvePath(filePath, currentWorkingDir);
    try {
        fs.mkdirSync(path.dirname(resolved), { recursive: true });
        const buf = Buffer.from(content, "utf8");
        fs.writeFileSync(resolved, buf, { flag: append ? "a" : "w" });
        return ok({ bytes_written: buf.length });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_list_dir", {
    title: "List Directory",
    description: "List files and directories with name, type, size, and last-modified time. Defaults to the current working directory.",
    inputSchema: z.object({
        path: z.string().optional().describe("Directory path (default: cwd)"),
        show_hidden: z.boolean().default(false).describe("Include dot-files"),
    }),
    annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ path: dirPath, show_hidden }) => {
    const resolved = dirPath ? resolvePath(dirPath, currentWorkingDir) : currentWorkingDir;
    try {
        const names = fs.readdirSync(resolved);
        const entries = names
            .filter((n) => show_hidden || !n.startsWith("."))
            .map((name) => {
            try {
                const stat = fs.lstatSync(path.join(resolved, name));
                const type = stat.isDirectory()
                    ? "dir"
                    : stat.isSymbolicLink()
                        ? "symlink"
                        : stat.isFile()
                            ? "file"
                            : "other";
                return { name, type, size: stat.size, modified: fmtTime(stat.mtime) };
            }
            catch {
                return { name, type: "other", size: 0, modified: "" };
            }
        });
        return ok({ entries });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_env", {
    title: "Get Environment Variables",
    description: "Return environment variables for this process. " +
        "Sensitive names (SECRET, PASSWORD, TOKEN, KEY, CREDENTIAL) are masked unless SHOW_SECRETS=1. " +
        "Use filter to narrow results.",
    inputSchema: z.object({
        filter: z.string().optional().describe("Case-insensitive substring filter on variable names"),
    }),
    annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ filter }) => {
    const SENSITIVE = /secret|password|token|key|credential/i;
    const result = {};
    for (const [k, v] of Object.entries(process.env)) {
        if (filter && !k.toLowerCase().includes(filter.toLowerCase()))
            continue;
        result[k] =
            process.env.SHOW_SECRETS === "1"
                ? (v ?? "")
                : SENSITIVE.test(k)
                    ? "***"
                    : (v ?? "");
    }
    return ok(result);
});
server.registerTool("terminal_edit_file", {
    title: "Edit File",
    description: "Apply one or more find-and-replace operations to a file in order. " +
        "Each old_str must exist exactly once. " +
        "Use dry_run=true to preview without writing, show_result=true to include the final content in the response.",
    inputSchema: z.object({
        path: z.string().min(1).describe("Path to the file to edit"),
        edits: z
            .array(z.object({
            old_str: z.string().describe("Exact text to find (must appear exactly once)"),
            new_str: z.string().describe("Replacement text (empty string to delete)"),
        }))
            .min(1)
            .describe("Ordered list of find-and-replace operations"),
        dry_run: z.boolean().default(false).describe("Preview without writing to disk"),
        show_result: z.boolean().default(false).describe("Include full file content in response"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ path: filePath, edits, dry_run, show_result }) => {
    const resolved = resolvePath(filePath, currentWorkingDir);
    let current;
    try {
        current = fs.readFileSync(resolved, "utf8");
    }
    catch {
        return err(`File not found: ${resolved}`);
    }
    let editsApplied = 0;
    for (const { old_str, new_str } of edits) {
        const count = current.split(old_str).length - 1;
        if (count === 0)
            return err(`old_str not found: ${JSON.stringify(old_str)}`);
        if (count > 1)
            return err(`old_str matches ${count} times (must be unique): ${JSON.stringify(old_str)}`);
        current = current.replace(old_str, new_str);
        editsApplied++;
    }
    if (!dry_run) {
        fs.mkdirSync(path.dirname(resolved), { recursive: true });
        fs.writeFileSync(resolved, current, "utf8");
    }
    const response = { edits_applied: editsApplied };
    if (dry_run)
        response.dry_run = true;
    if (show_result)
        response.content = truncate(current);
    return ok(response);
});
server.registerTool("terminal_mkdir", {
    title: "Create Directory",
    description: "Create a directory including any missing parents. Idempotent — succeeds silently if it already exists.",
    inputSchema: z.object({
        path: z.string().min(1).describe("Directory path to create"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ path: dirPath }) => {
    const resolved = resolvePath(dirPath, currentWorkingDir);
    try {
        const alreadyExists = fs.existsSync(resolved);
        if (alreadyExists) {
            if (!fs.statSync(resolved).isDirectory()) {
                return err(`Path exists but is not a directory: ${resolved}`);
            }
            return ok({ created: false });
        }
        fs.mkdirSync(resolved, { recursive: true });
        return ok({ created: true });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_delete", {
    title: "Delete File or Directory",
    description: "Delete a file or directory. Directories are removed recursively by default. " +
        "Set recursive=false to fail on non-empty directories.",
    inputSchema: z.object({
        path: z.string().min(1).describe("Path to delete"),
        recursive: z.boolean().default(true).describe("Delete directory contents recursively"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ path: targetPath, recursive }) => {
    const resolved = resolvePath(targetPath, currentWorkingDir);
    try {
        if (!fs.existsSync(resolved))
            return err(`Not found: ${resolved}`);
        const isDir = fs.statSync(resolved).isDirectory();
        if (isDir)
            fs.rmSync(resolved, { recursive, force: false });
        else
            fs.unlinkSync(resolved);
        return ok({ deleted: resolved });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_copy", {
    title: "Copy File or Directory",
    description: "Copy a file or directory to a new location (recursive for directories). " +
        "If destination is an existing directory, the source is copied inside it.",
    inputSchema: z.object({
        source: z.string().min(1).describe("Source path"),
        destination: z.string().min(1).describe("Destination path or directory"),
        overwrite: z.boolean().default(false).describe("Overwrite if destination already exists"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ source, destination, overwrite }) => {
    const resolvedSrc = resolvePath(source, currentWorkingDir);
    let resolvedDst = resolvePath(destination, currentWorkingDir);
    try {
        if (!fs.existsSync(resolvedSrc))
            return err(`Source not found: ${resolvedSrc}`);
        const srcType = fs.statSync(resolvedSrc).isDirectory() ? "directory" : "file";
        if (fs.existsSync(resolvedDst) && fs.statSync(resolvedDst).isDirectory()) {
            resolvedDst = path.join(resolvedDst, path.basename(resolvedSrc));
        }
        if (fs.existsSync(resolvedDst) && !overwrite) {
            return err(`Destination already exists: ${resolvedDst}. Use overwrite=true.`);
        }
        fs.mkdirSync(path.dirname(resolvedDst), { recursive: true });
        fs.cpSync(resolvedSrc, resolvedDst, { recursive: true, force: overwrite });
        return ok({ destination: resolvedDst, type: srcType });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_move", {
    title: "Move / Rename File or Directory",
    description: "Move or rename a file or directory. " +
        "If destination is an existing directory, the source is moved inside it. " +
        "Falls back to copy+delete for cross-device moves.",
    inputSchema: z.object({
        source: z.string().min(1).describe("Source path"),
        destination: z.string().min(1).describe("Destination path or directory"),
        overwrite: z.boolean().default(false).describe("Overwrite if destination already exists"),
    }),
    annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
        openWorldHint: false,
    },
}, async ({ source, destination, overwrite }) => {
    const resolvedSrc = resolvePath(source, currentWorkingDir);
    let resolvedDst = resolvePath(destination, currentWorkingDir);
    try {
        if (!fs.existsSync(resolvedSrc))
            return err(`Source not found: ${resolvedSrc}`);
        const srcType = fs.statSync(resolvedSrc).isDirectory() ? "directory" : "file";
        if (fs.existsSync(resolvedDst) && fs.statSync(resolvedDst).isDirectory()) {
            resolvedDst = path.join(resolvedDst, path.basename(resolvedSrc));
        }
        if (fs.existsSync(resolvedDst) && !overwrite) {
            return err(`Destination already exists: ${resolvedDst}. Use overwrite=true.`);
        }
        fs.mkdirSync(path.dirname(resolvedDst), { recursive: true });
        try {
            fs.renameSync(resolvedSrc, resolvedDst);
        }
        catch (renameErr) {
            if (renameErr.code === "EXDEV") {
                // Cross-device: copy then delete
                fs.cpSync(resolvedSrc, resolvedDst, { recursive: true, force: overwrite });
                fs.rmSync(resolvedSrc, { recursive: true, force: true });
            }
            else {
                throw renameErr;
            }
        }
        return ok({ destination: resolvedDst, type: srcType });
    }
    catch (e) {
        return err(e.message);
    }
});
server.registerTool("terminal_search_files", {
    title: "Search Files",
    description: "Recursively search for files/directories by name substring. " +
        "Applies include_pattern (whitelist) and exclude_pattern (blacklist) to entry names (case-insensitive).",
    inputSchema: z.object({
        path: z.string().optional().describe("Root directory to search (default: cwd)"),
        include_pattern: z.string().optional().describe("Include entries whose name contains this"),
        exclude_pattern: z.string().optional().describe("Exclude entries whose name contains this"),
        type: z.enum(["file", "directory", "any"]).default("any").describe("Filter by entry type"),
        max_depth: z.number().int().min(1).max(50).default(10).describe("Max recursion depth"),
        max_results: z.number().int().min(1).max(1000).default(200).describe("Max results to return"),
        show_hidden: z.boolean().default(false).describe("Include hidden entries (starting with .)"),
    }),
    annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
    },
}, async ({ path: searchPath, include_pattern, exclude_pattern, type, max_depth, max_results, show_hidden }) => {
    const root = searchPath ? resolvePath(searchPath, currentWorkingDir) : currentWorkingDir;
    const results = [];
    let capped = false;
    function walk(dir, depth) {
        if (depth > max_depth || capped)
            return;
        let names;
        try {
            names = fs.readdirSync(dir);
        }
        catch {
            return;
        }
        for (const name of names) {
            if (capped)
                return;
            if (!show_hidden && name.startsWith("."))
                continue;
            const full = path.join(dir, name);
            let stat;
            try {
                stat = fs.lstatSync(full);
            }
            catch {
                continue;
            }
            const isDir = stat.isDirectory();
            const entryType = isDir ? "directory" : "file";
            if (type !== "any" && entryType !== type) {
                if (isDir)
                    walk(full, depth + 1);
                continue;
            }
            const nameLower = name.toLowerCase();
            if (include_pattern && !nameLower.includes(include_pattern.toLowerCase())) {
                if (isDir)
                    walk(full, depth + 1);
                continue;
            }
            if (exclude_pattern && nameLower.includes(exclude_pattern.toLowerCase())) {
                if (isDir)
                    walk(full, depth + 1);
                continue;
            }
            results.push({ path: full, type: entryType, size: stat.size, modified: fmtTime(stat.mtime) });
            if (results.length >= max_results) {
                capped = true;
                return;
            }
            if (isDir)
                walk(full, depth + 1);
        }
    }
    try {
        if (!fs.existsSync(root))
            return err(`Search root not found: ${root}`);
        walk(root, 0);
        const response = { results };
        if (capped)
            response.capped = true;
        return ok(response);
    }
    catch (e) {
        return err(e.message);
    }
});
// ─── Transport ────────────────────────────────────────────────────────────────
async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("terminal-mcp-server running on stdio");
}
main().catch((e) => {
    console.error("Fatal error:", e);
    process.exit(1);
});
//# sourceMappingURL=index.js.map