Forked from youssef/terminal-mcp-server
src / index.ts
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: string): { safe: boolean; reason?: string } {
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: string): string {
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: Date): string {
return d.toISOString().slice(0, 16).replace("T", " ");
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
function resolvePath(userPath: string, cwd: string): string {
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(): string {
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: string = resolveStartingCwd();
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Compact MCP text response */
function ok(data: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(data) }] };
}
function err(message: string) {
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<{ stdout: string; stderr: string }>(
(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: Record<string, unknown> = { exit_code: 0 };
if (stdout.trim()) result.stdout = truncate(stdout);
if (stderr.trim()) result.stderr = truncate(stderr);
return ok(result);
} catch (e: unknown) {
clearTimeout(timer);
const isAbort =
(e as NodeJS.ErrnoException).code === "ABORT_ERR" ||
(e as Error).name === "AbortError";
if (isAbort) {
return ok({ exit_code: 124, timed_out: true, stderr: `Killed after ${timeout_ms}ms` });
}
const execErr = e as { stdout?: string; stderr?: string; code?: number };
const result: Record<string, unknown> = { 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 as Error).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: Record<string, unknown> = {
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 as Error).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 as Error).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 as Error).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: Record<string, string> = {};
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: string;
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: Record<string, unknown> = { 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 as Error).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 as Error).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 as Error).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 as NodeJS.ErrnoException).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 as Error).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;
interface SearchEntry {
path: string;
type: "file" | "directory";
size: number;
modified: string;
}
const results: SearchEntry[] = [];
let capped = false;
function walk(dir: string, depth: number): void {
if (depth > max_depth || capped) return;
let names: string[];
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: fs.Stats;
try { stat = fs.lstatSync(full); } catch { continue; }
const isDir = stat.isDirectory();
const entryType: "file" | "directory" = 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: Record<string, unknown> = { results };
if (capped) response.capped = true;
return ok(response);
} catch (e) {
return err((e as Error).message);
}
}
);
// ─── Transport ────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
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);
});