TypeScript

Wrap a CLI as an MCP tool when there is no API

The thing you want an agent to drive only has a CLI. execFile it, hand back stdout, and put bounds on the call so it cannot eat your process.

25 Jun 2026

No HTTP API, just a binary over SSH. Promisify execFile, set the limits up front, return the text.

import { execFile } from "node:child_process";
import { promisify } from "node:util";
 
const run = promisify(execFile);
 
interface SshTarget {
  keyPath: string;
  user: string;
  host: string;
}
 
export async function dokku(t: SshTarget, args: string[]): Promise<string> {
  const { stdout } = await run(
    "ssh",
    [
      "-i",
      t.keyPath,
      "-o",
      "StrictHostKeyChecking=accept-new",
      `${t.user}@${t.host}`,
      "dokku",
      ...args,
    ],
    { maxBuffer: 5 * 1024 * 1024, timeout: 60_000 },
  );
  return stdout;
}
 
// Want structured data out of a REPL? Make the REPL emit JSON.
export async function mongoJson(uri: string, expr: string): Promise<unknown> {
  const { stdout } = await run(
    "mongosh",
    [uri, "--quiet", "--eval", `JSON.stringify(${expr})`],
    { maxBuffer: 5 * 1024 * 1024, timeout: 60_000 },
  );
  return JSON.parse(stdout);
}

Gotchas

Set maxBuffer and timeout or large output and hung connections will bite you. The default buffer is small, so a chatty command throws ENOBUFS mid-stream and you lose the output you wanted; without a timeout a stalled SSH session hangs the tool call forever. For a REPL like mongosh, --quiet plus JSON.stringify(...) is what turns a human-shaped dump into something JSON.parse can read.

Related
now runningwhisper_scheduleopen