run_process
Execute a shell command or spawn a process on Linux with configurable arguments, working directory, stdin, and timeout.
Instructions
Run a process on this linux machine
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| command_line | No | Shell mode: a shell command line executed via the system's default shell. Supports pipes, redirects, globbing. Cannot be combined with 'argv'. | |
| argv | No | Executable mode: directly spawn a process. argv[0] is the executable, followed by arguments passed verbatim (no shell interpretation). Cannot be combined with 'command_line'. | |
| cwd | No | Optional to set working directory | |
| stdin_text | No | Optional text written to STDIN (written fully, then closed). Useful for heredoc-style input or file contents. | |
| timeout_ms | No | Optional timeout in milliseconds, defaults to 30,000ms |
Implementation Reference
- src/run_process.ts:92-266 (handler)The main handler function `runProcess()` that executes the tool logic. It accepts RunProcessArgs, spawns a child process (either shell mode via command_line or executable mode via argv), handles stdin input, timeout with SIGTERM/SIGKILL, collects stdout/stderr, and returns a CallToolResult promise.
export function runProcess( runProcessArgs: RunProcessArgs, ): SpawnPromise { const startTime = performance.now(); const args = new RunProcessArgsHelper(runProcessArgs); if (args.isShellMode && args.isExecutableMode) { return Promise.resolve(errorResult("Cannot pass both 'command_line' and 'argv'. Use one or the other.")); } if (!args.isShellMode && !args.isExecutableMode) { return Promise.resolve(errorResult("Either 'command_line' (string) or 'argv' (array) is required.")); } const options: ObjectEncodingOptions & SpawnOptions = { // spawn options: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options encoding: "utf8" }; if (args.cwd) { options.cwd = args.cwd; } let spawnCommand = ""; let spawnArgs: string[] = []; if (args.isShellMode) { (options as any).shell = true; spawnCommand = String(args.commandLine); spawnArgs = []; } else { (options as any).shell = false; const argv = args.argv as string[]; spawnCommand = argv[0]; spawnArgs = argv.slice(1); } const logWithElapsedTime = (msg: string, ...rest: any[]) => { if (!is_verbose) return; const elapsed = ((performance.now() - startTime) / 1000).toFixed(3); verbose_log(`[${elapsed}s] ${msg}`, ...rest, spawnCommand, spawnArgs); }; let child_pid; const promise: SpawnPromise = new Promise<CallToolResult>((resolve, reject) => { if (!args.stdin_text) { // PRN windowsHide on Windows, signal, killSignal // FYI spawn_options.stdio => default is perfect ['pipe', 'pipe', 'pipe'] // order: [STDIN, STDOUT, STDERR] // https://nodejs.org/api/child_process.html#optionsstdio // 'ignore' attaches /dev/null // do not set 'inherit' (causes ripgrep to see STDIN socket and search it, thus hanging) options.stdio = ['ignore', 'pipe', 'pipe']; } // remove timeout on spawn options (if set) so the built‑in spawn timeout does not interfere delete (options as any).timeout; // Use a detached child so we can kill the entire process group. options.detached = true; let settled = false; const settle = (result: SpawnResult, isError: boolean) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (isError) { resolve(resultFor(result)); } else { resolve(resultFor(result)); } }; const child = spawn(spawnCommand, spawnArgs, options); logWithElapsedTime(`START SPAWN child.pid: ${child.pid}`); child_pid = child.pid; let stdout = ""; let stderr = ""; if (child.stdin && args.stdin_text) { child.stdin.write(args.stdin_text); child.stdin.end(); } if (child.stdout) { child.stdout.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); }); } if (child.stderr) { child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); }); } child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { // child process streams MAY still be open when EXIT is emitted (use close if need to ensure they're closed) // "close" will come after "exit" once process is terminated + streams are closed // so for now use "close" to determine if process was terminated too, that way you can access STDOUT/STDERR reliably for returning full output to agent logWithElapsedTime("EXIT", { code, signal }); }); child.on("spawn", () => { // emitted after child process starts successfully // if child doesn't start, error emitted instead // emitted BEFORE any data received via stdout/stderr logWithElapsedTime("SPAWN") }); // Timeout handling – kill the whole process group after the supplied timeout. let timer: NodeJS.Timeout | null = null; timer = setTimeout(() => { if (process.platform !== "win32") { if (child.pid) { try { process.kill(-child.pid, "SIGTERM"); } catch (_) {} } } else { child.kill("SIGTERM"); } const killTimeout = setTimeout(() => { if (process.platform !== "win32") { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch (_) {} } } else { child.kill("SIGKILL"); } }, 2000); const clearKill = () => clearTimeout(killTimeout); child.once("exit", clearKill); child.once("close", clearKill); }, args.timeoutMs); child.on("error", (err: Error) => { logWithElapsedTime("ERROR"); // ChildProcess 'error' docs: https://nodejs.org/api/child_process.html#event-error // error running process // IIUC not just b/c of command failed w/ non-zero exit code const result: SpawnFailure = { stdout, stderr, // // one of these will always be non-null code: (err as any).code, // set if process exited, else null signal: (err as any).signal, // set if process was terminated by signal, else null // message: err.message, // ? killed: (err as any).killed }; logWithElapsedTime("ERROR_RESULT", result); settle(result, true); }); child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { logWithElapsedTime("CLOSE"); // ChildProcess 'close' docs: https://nodejs.org/api/child_process.html#event-close // 'close' is after child process ends AND stdio streams are closed // - after 'exit' or 'error' // either code is set, or signal, but NOT BOTH // signal if process killed // FYI close does not mean code==0 const result: SpawnResult = { stdout, stderr, // code: code ?? undefined, signal: signal ?? undefined, // }; logWithElapsedTime("CLOSE_RESULT", result); settle(result, false); }); }); // FYI later (when needed) I can map this onto the promise that comes back from runProcess too (and tie into that unit test I have that needs pid to terminate it) // Resolve the underlying spawn result, then map to CallToolResult including PID. promise.pid = child_pid; return promise } - src/run_process.ts:30-90 (schema)Type definitions: RunProcessArgs (Record<string, unknown>), RunProcessArgsHelper class with typed getters (cwd, stdin_text, commandLine, argv, timeoutMs), and SpawnResult/SpawnFailure types.
/** * Raw arguments passed to {@link runProcess}. Historically this was a loose * {@link Record} but we now expose a typed helper for safer access. */ export type RunProcessArgs = Record<string, unknown>; /** * Helper class that provides typed getters for the keys accepted by * {@link runProcess}. It wraps a {@link RunProcessArgs} object and casts the * values to the expected runtime types. */ export class RunProcessArgsHelper { private readonly raw: RunProcessArgs; constructor(raw: RunProcessArgs) { this.raw = raw ?? {}; } /** Working directory – string if supplied, otherwise undefined */ get cwd(): string | undefined { const v = this.raw.cwd; return v == null ? undefined : String(v); } /** Text to write to STDIN – string if supplied, otherwise undefined */ get stdin_text(): string | undefined { const v = this.raw.stdin_text; return v == null ? undefined : String(v); } /** Shell command line – string if supplied, otherwise undefined */ get commandLine(): string | undefined { const v = this.raw.command_line; return v == null ? undefined : String(v); } /** Executable argv – array of strings if supplied, otherwise undefined */ get argv(): string[] | undefined { const v = this.raw.argv; if (!Array.isArray(v)) return undefined; return v.map((item) => String(item)); } /** Timeout in milliseconds – number if supplied, otherwise undefined */ /** Timeout in milliseconds – always a number (default 30_000) */ get timeoutMs(): number { const v = this.raw.timeout_ms; const n = Number(v); return Number.isNaN(n) ? 30_000 : n; } /** True if a shell command line is provided */ get isShellMode(): boolean { return Boolean(this.commandLine); } /** True if an argv array with at least one element is provided */ get isExecutableMode(): boolean { return Array.isArray(this.raw.argv) && (this.argv?.length ?? 0) > 0; } } - src/tools.ts:12-82 (registration)The `registerTools()` function registers the tool on the MCP server. It sets ListToolsRequestSchema handler (tool definition with name 'run_process', description, inputSchema) and CallToolRequestSchema handler (routes to runProcess()).
export function registerTools(server: Server) { server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => { verbose_log("INFO: ListTools"); return { // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool // tool definition // https://modelcontextprotocol.io/docs/learn/architecture#understanding-the-tool-execution-request // tool request/response // typescript SDK docs: // servers: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/server.md // TODO upgrade to newer version AND check if STDIO delimiter style has changed to include content-length "header" before responses? // OR is there an opt-in for this style vs what I get with my simple nvim uv.spawn nvim client... where \n terminates/delimits each message tools: [ { // TODO RUN_PROCESS MIGRATION! provide examples in system message, that way it is very clear how to use these! name: "run_process", description: "Run a process on this " + os.platform() + " machine", inputSchema: { type: "object", properties: { // ListToolsResult => Tool type (in protocol) => https://modelcontextprotocol.io/specification/2025-06-18/schema#tool command_line: { type: "string", description: "Shell mode: a shell command line executed via the system's default shell. Supports pipes, redirects, globbing. Cannot be combined with 'argv'." }, argv: { minItems: 1, // * made up too type: "array", items: { type: "string" }, description: "Executable mode: directly spawn a process. argv[0] is the executable, followed by arguments passed verbatim (no shell interpretation). Cannot be combined with 'command_line'." }, cwd: { // or "workdir" like before? => eval model behavior w/ each name? type: "string", description: "Optional to set working directory", }, stdin_text: { type: "string", description: "Optional text written to STDIN (written fully, then closed). Useful for heredoc-style input or file contents." }, timeout_ms: { type: "number", description: "Optional timeout in milliseconds, defaults to 30,000ms", } }, // FYI no required arg top level and I am not gonna fret about specifiying one or the other, the tool definition is fine with that distinction in the descriptions, plus it is intuitive. // and back when I had mode=shell/executable required, models would still forget to add it so why bother with a huge complexity in tool definition }, }, ], }; }); server.setRequestHandler( CallToolRequestSchema, async (request): Promise<CallToolResult> => { verbose_log("INFO: ToolRequest", request); switch (request.params.name) { case "run_process": { if (!request.params.arguments) { throw new Error("Missing arguments for run_process"); } const result = await runProcess(request.params.arguments); // FYI logging this response is INVALUABLE! found a problem with my neovim MCP STDIO client! verbose_log("INFO: ToolResponse", result); return result; } default: throw new Error("Unknown tool"); } } ); } - src/index.ts:1-42 (registration)Server entrypoint: creates the MCP Server, calls registerTools(server) to register the run_process tool, and connects via StdioServerTransport.
#!/usr/bin/env node import os from "os"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "module"; import { registerPrompts } from "./prompts.js"; import { registerTools } from "./tools.js"; const require = createRequire(import.meta.url); const { name: package_name, version: package_version, } = require("../package.json"); const server = new Server( { name: package_name, version: package_version, description: "Run commands on this " + os.platform() + " machine", }, { capabilities: { //resources: {}, tools: {}, prompts: {}, //logging: {}, // for logging messages that don't seem to work yet or I am doing them wrong }, } ); registerTools(server); registerPrompts(server); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { console.error("Server error:", error); process.exit(1); }); - src/messages.ts:1-91 (helper)Helper functions resultFor() and messagesFor() that convert SpawnResult into CallToolResult format with properly structured TextContent messages (EXIT_CODE, STDOUT, STDERR, SIGNAL, etc.), and errorResult() for validation errors.
import { SpawnFailure, SpawnResult } from "./run_process.js"; import { CallToolResult, TextContent } from "@modelcontextprotocol/sdk/types.js"; export function resultFor(spawn_result: SpawnResult): CallToolResult { const result_obj: CallToolResult = { content: messagesFor(spawn_result), } if (spawn_result.code !== 0) { result_obj.isError = true; } return result_obj } export function messagesFor(result: SpawnFailure | SpawnResult): TextContent[] { const messages: TextContent[] = []; if (result.code !== undefined) { // FYI include EXIT_CODE always, to make EXPLICIT when it is NOT a FAILURE! // will double underscore when a comman fails vs not stating if it was a failure // some commands give no other indication of success! messages.push({ name: "EXIT_CODE", type: "text", text: String(result.code), }); } // // PRN map COMMAND for failures so model can adjust what it passes vs what actually ran? // if ("cmd" in result && result.cmd) { // messages.push({ // name: "COMMAND", // type: "text", // text: result.cmd, // }); // } if ("message" in result && result.message) { // at least need error.message from spawn errors messages.push({ name: "MESSAGE", type: "text", text: result.message, }); } if (result.signal) { messages.push({ name: "SIGNAL", type: "text", text: result.signal, }); } // // when is this set? what conditions? I tried `kill -9` and didn't trigger "error" event // if ("killed" in result && result.killed) { // // killed == true is the only time to include this // messages.push({ // name: "KILLED", // type: "text", // text: "Process was killed", // }); // } if (result.stdout) { messages.push({ name: "STDOUT", type: "text", text: result.stdout, }); } if (result.stderr) { messages.push({ name: "STDERR", type: "text", text: result.stderr, }); } return messages; } export function errorResult(message: string): CallToolResult { return { isError: true, content: [{ name: "ERROR", type: "text", text: message, }], }; }