Any MCP tool that builds a shell command from one of its arguments — to run git, ffmpeg, pandoc, curl, or any other command-line program — inherits a vulnerability as old as the shell itself: command injection. The tool isn’t malicious and the model isn’t doing anything exotic. It just passes a string into a tool called run_git_command or convert_file, and if that string reaches a shell unescaped, whatever the shell can do, the tool can now be made to do.

What command injection means in an MCP tool

Classic command injection is a program that builds a shell command by gluing a string together — `git log {branch}` — and hands it to something like a shell’s system() call or Node’s child_process.exec. If branch is supposed to be a branch name but actually contains main; rm -rf ~, the shell doesn’t know the difference between the command the developer meant and the one the input just handed it. It runs both.

An MCP tool with a free-text argument is the same shape, with the same wrinkle we covered in our piece on SSRF in URL-fetching tools: the thing filling in the argument isn’t a person typing at a terminal, it’s a model following an instruction — including one it picked up from a document, a web page, or a tool result it was never supposed to treat as a command. See how prompt injection actually reaches a tool call for how an instruction gets into the model’s context in the first place. A tool that shells out doesn’t know whether the string in its argument came from the person who typed the original request or from a line buried in a file that request asked it to process.

Where this shows up in real MCP servers

Any server whose job is to wrap a command-line tool is a candidate: git helpers, media converters, PDF and document processors, build and deployment tools, anything that shells out to a system utility instead of calling a library function directly. It’s especially easy to introduce by accident if you’re turning an existing script or CLI into an MCP server — the fastest way to wrap a command-line program is to forward the model’s argument straight into a shell string, which is exactly the unsafe path. None of this is a knock on any specific tool; running a real command is often the entire point of the server, and the risk comes from how the command gets built, not from offering the capability at all.

It’s worse for a local, stdio-launched server than a remote one: a local server runs as your own user, with your own file permissions and your own shell environment, so an injected command runs with everything you can already do on your machine. A remote server at least confines the blast radius to whatever its own host can reach.

What an injected argument can actually do

  • Chain an unrelated command. Shell metacharacters like ;, &&, |, and backticks let an argument that was supposed to be a filename or a branch name append a second, completely different command for the shell to run next.
  • Read or exfiltrate files the process can see. A command tacked onto a file-processing tool can read environment variables, SSH keys, or any file the process user has access to, then write or send the contents somewhere the model’s next tool call can pick them up — one of the paths covered in data exfiltration through MCP.
  • Abuse flags the wrapped program itself exposes. Even without a shell metacharacter, some CLI tools accept flags that read or write arbitrary paths (an output path, an include path, a config file) — passed straight through by a tool that only validated that its argument looked like a normal value.
  • Persist beyond the single call. A write to a shell startup file, a cron entry, or a git hook turns a one-time injected command into something that keeps running after the conversation ends.

None of this needs the model to be unusually clever — just a tool that passes a string to a shell, and a path, such as a crafted filename in a scraped repository or an injected instruction, that puts the wrong string in that argument.

If you’re connecting a server like this

  • Check what the tool actually wraps. A tool description that says “runs git commands” or “converts files with ffmpeg” is telling you it shells out; read the parameter list for anything that looks like a raw command string rather than a constrained value.
  • Run a free MCP security scan before connecting. gate’s scanner lists every tool and parameter a server exposes, so a command-shaped argument is visible before you grant the server access to anything.
  • Keep anything that shells out behind an approval step rather than blanket allow — see our patterns for human-in-the-loop tool approvals for what that looks like in practice.
  • Treat it as one line item in a normal pre-connection vetting pass, alongside the rest of a server’s tool list, not a reason to avoid every CLI-wrapping server.

If you’re building a tool that wraps a command

  • Never build a command by string concatenation or interpolation. Use your language’s argument-array form — execve-style APIs, Python’s subprocess.run with a list and no shell=True, Node’s execFile or spawn instead of exec — so each argument reaches the program as a single, literal value the shell never re-parses.
  • Validate against an allow-list, not a block-list. If an argument is supposed to be a branch name, a filename, or an enum, check that it matches the expected shape and reject anything else, rather than trying to strip out every dangerous character you can think of.
  • Run the underlying program with the least privilege it needs, and consider a sandbox or container boundary for anything that processes untrusted input, so a successful injection is contained even when a validation gap slips through.
  • Say what you allow, in the tool description. “Branch name must match ^[\w./-]+$” lets anyone evaluating the tool — a person or a scanner — see the constraint without reading your source, which is also a description a model can act on correctly.
  • If the tool genuinely needs to run arbitrary commands, say so explicitly and scope its execution environment narrowly instead of leaving it open by omission — see excessive agency for the broader version of this mistake.
How gate helps: gate can’t rewrite a third-party server’s internal command-building logic, but it can limit what reaches a shell-capable tool in the first place. Set a command-wrapping tool to ask instead of allow so a questionable call needs a human sign-off instead of running silently, and every tool call is logged with its full arguments, so if a command tool is ever handed something it shouldn’t have accepted, the exact string it was given is in the record. The free security scanner flags over-broad permissions and hidden instructions in a server’s tool descriptions before you ever connect it. More on the layered approach on the MCP security page.

The bottom line

A tool that runs git, ffmpeg, or any other command-line program is doing exactly what it says — the risk isn’t that it lies about its job, it’s that an argument meant to be one value and a command meant to run instead are the same string unless someone deliberately keeps them apart. If you’re connecting one, read the parameter list for anything shaped like a raw command before assuming it’s constrained. If you’re building one, an argument array instead of a concatenated string is a handful of lines of code, and it’s a lot cheaper to write before the tool ships than to explain after it ran a command nobody meant to send. Browse gate’s MCP server directory to see how existing servers describe the commands they wrap before you connect one yourself.