A lot of the tools worth giving an AI client access to don’t have an API at all — they have a command line. git, ffmpeg, pandoc, aws, docker, jq, a deploy script someone wrote three years ago. Our piece on turning an API into an MCP server covers the case where a spec already exists to generate from. A CLI gives you no spec, no schema, and no generator to point at it — you’re deciding, by hand, what becomes a tool and how a model is allowed to call it.

Why this is a different job than API conversion

An OpenAPI spec hands a generator the operation names, parameters, and types it needs. A CLI gives you a --help output and, if you’re lucky, a man page. There’s no machine-readable contract to convert — you read the help text yourself and decide what a tool should look like. That’s more work up front, but it also means you aren’t stuck with whatever shape an auto-converter produced; every tool you expose is one you chose to expose.

Map commands to jobs, not flags to parameters

The tempting shortcut is one tool per subcommand, with every CLI flag passed through as a tool input. That mirrors the one-tool-per-endpoint trap from API conversion, and it has the same cost: a model now has to pick the right combination of flags from the full surface of the program, instead of calling a tool that already encodes the job. If your users only ever run ffmpeg to shrink a video for the web, build a compress_video tool with two or three sane inputs, not a thin wrapper over every ffmpeg flag that exists. We go deeper on this trade-off in designing MCP tools around agent jobs, not API endpoints.

The one rule that actually matters: never build a shell string

This is the part to get right before anything else, because getting it wrong is a security bug, not a style choice. The moment a tool takes the model’s argument and glues it into a command string — `ffmpeg -i ${input} out.mp4` — you’ve built the exact shape covered in command injection in MCP tools: if that argument ever contains a shell metacharacter, intentionally or because the model picked it up from a document it was processing, the shell runs whatever was appended. Use your language’s argument-array form instead — Python’s subprocess.run([...], shell=False), Node’s execFile or spawn — so each value reaches the program as a single literal argument the shell never re-parses. That one choice, made once, closes off the entire class of attack. If you want the full picture of what an unsafe version can be made to do, that linked post covers it.

Exit codes and stderr aren’t MCP errors — translate them

A CLI program communicates failure with a non-zero exit code and a message on stderr. MCP has its own channel for a failed operation that isn’t a protocol-level problem: a normal tool result with isError set to true, carrying a message the model can actually act on. Catch the process exit code yourself and translate it — don’t let a raw stack trace or a cryptic one-line stderr dump become the only thing the model sees. Our post on handling errors in an MCP tool covers what makes an error message useful instead of just technically present, and JSON-RPC in MCP explains the separate failure channel underneath all of it.

Output is usually text, but not always small

Most CLI tools print to stdout, which maps cleanly to a text content block. Two things break that: binary output (an image, a converted file) needs to be read from disk and returned as a separate content block rather than stuffed into text, and a command that can legitimately produce megabytes of output — a verbose log dump, a large diff — needs a cap or a way to page through it rather than handing all of it to the model at once. See paginating large MCP tool results for the patterns that work when a single call’s output doesn’t fit comfortably in context.

Set a timeout and a working directory on purpose

A CLI process you spawn doesn’t know it’s being called by an agent instead of a person at a terminal — it will happily hang waiting for interactive input, or run indefinitely on a large file. Set an explicit timeout and kill the process if it’s exceeded; don’t depend on the program’s own defaults. Pin the working directory and any paths the command can touch instead of inheriting whatever directory the server process happened to start in — the same least-privilege logic as excessive agency applies to a command that can read or write anywhere on disk because nobody told it it couldn’t.

Write a description that tells the model what it can’t do

A tool description generated by paraphrasing --help text tends to describe what the flag does and skip what the constraint is. “Branch name to check out” is weaker than “branch name, must match an existing local or remote branch — this does not create a new branch.” The more precisely the description states the boundary, the less a model has to guess, and the easier it is for a person or a scanner to spot a tool whose actual behavior is wider than its name suggests. Our guide to writing MCP tool descriptions goes through this in more detail.

Before you point an agent at it

A CLI-wrapping server you built yourself still deserves the same pre-flight pass as a third-party one. Run it through gate’s free MCP security scanner to see the exact tool list and parameters an AI client would see, catch any description that reads as broader than the command actually is, and treat anything destructive — delete, overwrite, deploy — as a separate case behind an approval step rather than blanket allow. Our pre-connection vetting checklist applies whether you wrote the server or someone else did, and the broader set of controls — per-tool allow / ask / block, a plain-language log of every call — lives on the MCP security page.

The honest summary: wrapping a CLI tool is more manual than converting an API, because there’s no spec to lean on — you’re the one deciding what a tool is, what it refuses to do, and how its failures get reported. The mechanical part (spawning a process, capturing output) is a few lines of code. The part that actually determines whether the result is safe to connect is the argument-array rule, the description that states its own limits, and governing the finished server the same way you would any other.

The bottom line

Turning a CLI tool into an MCP tool means deciding, job by job, what a model should be able to run and what it shouldn’t — there’s no generator to outsource that decision to. Build the command with an argument array, never a concatenated string; translate exit codes into a result the model can act on; cap what a single call can return; and describe every constraint explicitly instead of leaving it implied by a flag name. Then treat the finished server like any other piece of production surface: scan it, govern the destructive tools, and log what it actually does. Browse gate’s MCP server directory for examples of how existing servers scope what they expose before deciding how far to take your own.