A command-line tool you already trust makes a good MCP tool: it has a stable interface, it is installed on the box, and its behavior is documented. The work is not reimplementing it, it is exposing it safely — mapping subcommands and flags to typed inputs, feeding untrusted input as data rather than shell, bounding runtime, and turning a non-zero exit into a clean tool error instead of a stack trace.
This tutorial wraps jq with
FastMCP. jq is a good example because it is pure and
deterministic: give it a filter and a JSON document and you get the same output
every time, with meaningful exit codes when a filter or the input is bad. You will
build one hardened subprocess wrapper, expose a general tool and a deliberately
constrained one on top of it, and test both the mapping and the safety rules. The
stack is Mac-native: uv, make, and pytest.
The safety rules for wrapping a CLI
Every one of these is a line in the wrapper you are about to write. They apply to
any binary, not just jq:
- Never build a shell string. Pass an argument list to
subprocess.runand leaveshell=False(the default). A filter or payload can then never inject;,|,$(...), or backticks. - Untrusted input goes on stdin, not the command line. Large or hostile JSON is data piped to the process, never an argument the shell or the tool has to parse as a flag.
- Bound every run with a timeout. A pathological input must not hang the server.
- Cap the captured output. A tool result that returns megabytes can break the transport; refuse it instead.
- Normalize failures. Missing binary, timeout, and non-zero exit all become one error type the server converts into an MCP tool error carrying the tool’s own stderr.
What you will build
runner.py: the single module that touchessubprocess, enforcing all five rules above.server.py: two tools —jq_filter(arbitrary program plus output flags) andjq_keys(a constrained wrapper that can only list keys) — plus ajq://versionresource.client.py: an in-memory client that calls each tool, including a failing one.- A
pytestsuite covering the flag mapping, the constrained tool, shell-injection resistance, and the timeout. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- jq 1.7+ —
brew install jq; verifyjq --version. - uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP tools and the in-memory
Client(see Build an MCP Server with FastMCP and Design Great MCP Tools: Annotations and Semantics).
Step 1: Add project hygiene
Create the file
mkdir -p wrap-local-cli-mcp-macos
cd wrap-local-cli-mcp-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches never get committed.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "Wrap the jq command-line tool as MCP tools with FastMCP"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
- The only runtime dependency is FastMCP. The tool being wrapped is an external
binary on
PATH, not a Python package, so it is a system prerequisite rather than a dependency.
Step 3: The hardened subprocess wrapper
Put every subprocess concern in one place. Nothing else in the project calls
subprocess, so all the safety rules live and are tested here.
Create the file
touch runner.py
Add the code: runner.py
"""Run the `jq` CLI safely from Python.
The whole point of wrapping a command-line tool for MCP is to invoke it the way a
careful operator would, not the way a shell one-liner does. This module is the one
place that touches `subprocess`, and it enforces four rules every wrapped CLI
should follow:
1. Build an **argument list**, never a shell string — no `shell=True`, so a filter
or JSON payload can never inject `;`, `|`, `$(...)`, or backticks.
2. Feed untrusted input over **stdin**, not as an argument, so large or hostile
payloads are data, not command line.
3. Bound every run with a **timeout** so a pathological input cannot hang the
server.
4. **Cap the captured output** so a tool result cannot blow up the transport.
Errors from the CLI (missing binary, timeout, non-zero exit) are normalized into a
single `CLIError` the server layer turns into a clean MCP tool error.
"""
from __future__ import annotations
import shutil
import subprocess
# The binary we wrap. Resolved once so a clear error fires if it is not installed.
JQ = "jq"
DEFAULT_TIMEOUT = 5.0
MAX_OUTPUT_BYTES = 256 * 1024 # 256 KiB is plenty for a tool result.
class CLIError(Exception):
"""A wrapped-CLI invocation failed in a way the caller should see."""
def run_jq(
filter_expr: str,
json_input: str,
*,
raw: bool = False,
compact: bool = False,
sort_keys: bool = False,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Run `jq [flags] <filter>` with `json_input` on stdin and return stdout.
Flags are typed booleans that map to jq options, so the caller never assembles
a command line: `raw` -> `-r`, `compact` -> `-c`, `sort_keys` -> `-S`.
"""
if shutil.which(JQ) is None:
raise CLIError(f"'{JQ}' is not installed or not on PATH")
args = [JQ]
if raw:
args.append("-r")
if compact:
args.append("-c")
if sort_keys:
args.append("-S")
args.append(filter_expr) # a normal argv entry, not shell-interpolated
try:
proc = subprocess.run(
args,
input=json_input,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise CLIError(f"jq timed out after {timeout:g}s") from exc
if proc.returncode != 0:
# jq writes a human-readable reason to stderr; surface it verbatim.
reason = proc.stderr.strip() or f"exit code {proc.returncode}"
raise CLIError(f"jq failed: {reason}")
out = proc.stdout
if len(out.encode()) > MAX_OUTPUT_BYTES:
raise CLIError(f"jq output exceeded {MAX_OUTPUT_BYTES} bytes")
return out
def jq_version_string(timeout: float = 2.0) -> str:
"""Return the installed jq version, e.g. `jq-1.8.2`."""
if shutil.which(JQ) is None:
raise CLIError(f"'{JQ}' is not installed or not on PATH")
try:
proc = subprocess.run(
[JQ, "--version"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise CLIError(f"jq timed out after {timeout:g}s") from exc
return proc.stdout.strip()
Detailed breakdown
args = [JQ]thenargs.append(...)builds a real argument vector. Becausesubprocess.rungets a list andshellstaysFalse, the filter is handed tojqas one argument viaexecvp; there is no shell to interpret metacharacters. A filter of. ; rm -rf ~is just an invalid jq program, not a second command.input=json_inputstreams the JSON to jq’s stdin. The document never appears on the command line, so its size and contents cannot overflow argv or be misread as a flag.timeout=timeoutturns a runaway program (jq can loop forever with something likewhile(true; .)) into aTimeoutExpired, which becomes aCLIError.- The
returncodecheck captures jq’s own stderr message so the caller learns why the filter failed, not just that it did. len(out.encode()) > MAX_OUTPUT_BYTESrejects an oversized result rather than shipping it through the transport.shutil.which(JQ)gives a readable “not installed” error instead of a bareFileNotFoundErrorif jq is missing.
Step 4: Expose a general tool and a constrained one
Two tools wrap the same binary. jq_filter is the full-power operation; jq_keys
is deliberately narrow — it runs a fixed program and takes no filter at all. Giving
a model the smallest capability that does the job is the safe default.
Create the file
touch server.py
Add the code: server.py
"""Expose the `jq` CLI as MCP tools.
Two tools, both read-only, wrap one binary:
- `jq_filter` is the general operation — an arbitrary jq program plus the common
output flags, mapped to typed inputs.
- `jq_keys` is a *constrained* operation — it runs the fixed `keys` program, so a
caller that only needs object keys cannot pass a free-form filter at all.
Exposing a narrow tool alongside the general one is the safe-by-default pattern:
give a model the smallest capability that does the job. A `jq://version` resource
reports which jq is installed.
Run over stdio with `python server.py`, or drive it with client.py.
"""
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
from runner import CLIError, jq_version_string, run_jq
mcp = FastMCP(name="jq-tools")
READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)
@mcp.tool(annotations=READ_ONLY)
def jq_filter(
filter_expr: str,
json_input: str,
raw: bool = False,
compact: bool = False,
sort_keys: bool = False,
) -> str:
"""Run a jq program over a JSON document.
Args:
filter_expr: a jq filter, e.g. ".users[].name" or "keys".
json_input: the JSON document to read (passed to jq on stdin).
raw: emit raw strings without JSON quotes (jq -r).
compact: emit compact single-line JSON (jq -c).
sort_keys: sort object keys in the output (jq -S).
"""
try:
return run_jq(
filter_expr,
json_input,
raw=raw,
compact=compact,
sort_keys=sort_keys,
)
except CLIError as exc:
raise ToolError(str(exc)) from exc
@mcp.tool(annotations=READ_ONLY)
def jq_keys(json_input: str) -> str:
"""List the keys of a JSON object, sorted, as a compact array.
A constrained wrapper: it always runs the `keys` program, so no arbitrary jq
filter can be supplied through this tool.
"""
try:
return run_jq("keys", json_input, compact=True)
except CLIError as exc:
raise ToolError(str(exc)) from exc
@mcp.resource("jq://version")
def jq_version() -> str:
"""The version of the jq binary this server shells out to."""
try:
return jq_version_string()
except CLIError as exc:
raise ToolError(str(exc)) from exc
if __name__ == "__main__":
mcp.run()
Detailed breakdown
jq_filter’s signature is the flag map. Each boolean parameter corresponds to one jq option; FastMCP builds the tool’s input schema from the annotations, so the model sees typedraw/compact/sort_keysswitches instead of a raw command line. The docstringArgs:become the parameter descriptions.jq_keystakes onlyjson_input. It hardcodes thekeysprogram, so this tool cannot run an arbitrary filter no matter what a caller sends. When you only need one operation, expose that operation, not the whole tool.ToolAnnotations(readOnlyHint=True, ...)advertises that jq neither mutates state nor reaches the network. These are advisory hints surfaced intools/list, covered in the annotations article.except CLIError -> raise ToolErroris the boundary: the runner raises a plainCLIError, and the server converts it into aToolError, which the client receives as a readable tool error carrying jq’s stderr.- The
jq://versionresource reports the installed jq. Its value depends on the machine, so treat it as informational rather than a fixed string.
Step 5: Drive it in-memory
Create the file
touch client.py
Add the code: client.py
"""Drive the jq-tools server in-memory.
Lists the two tools and the version resource, then calls each tool: a general
filter (compact output), the constrained keys tool, and a filter that fails so you
can see the CLI's error surfaced as a clean MCP error.
"""
import asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
from server import mcp
SAMPLE = '{"name": "Ada", "langs": ["ada", "python"], "active": true}'
async def main() -> None:
async with Client(mcp) as client:
print("tools: ", [t.name for t in await client.list_tools()])
print("resources:", [str(r.uri) for r in await client.list_resources()])
print("version: ", (await client.read_resource("jq://version"))[0].text)
print("\njq_filter '.langs' (compact):")
r = await client.call_tool(
"jq_filter", {"filter_expr": ".langs", "json_input": SAMPLE, "compact": True}
)
print(" ", r.data.rstrip())
print("jq_filter '.name' (raw):")
r = await client.call_tool(
"jq_filter", {"filter_expr": ".name", "json_input": SAMPLE, "raw": True}
)
print(" ", r.data.rstrip())
print("jq_keys:")
r = await client.call_tool("jq_keys", {"json_input": SAMPLE})
print(" ", r.data.rstrip())
print("\njq_filter with a bad filter (errors):")
try:
await client.call_tool(
"jq_filter", {"filter_expr": ".[]", "json_input": "5"}
)
except ToolError as exc:
print(" ToolError:", exc)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
r.datais the tool’s string return value (jq’s stdout, which ends in a newline, so the demo.rstrip()s it for display).- The
.langsand.namecalls show the flag mapping in action:compact=Trueyields single-line JSON,raw=Truedrops the quotes around a string. jq_keysreturns the sorted key array with no filter argument supplied.- The failing call passes
.[]against the number5, which jq rejects; theCLIErrorbecomes aToolErrorthe client catches.
Run it:
uv run python client.py
Expected output (jq’s version reflects your install):
tools: ['jq_filter', 'jq_keys']
resources: ['jq://version']
version: jq-1.8.2
jq_filter '.langs' (compact):
["ada","python"]
jq_filter '.name' (raw):
Ada
jq_keys:
["active","langs","name"]
jq_filter with a bad filter (errors):
ToolError: jq failed: jq: error (at <stdin>:0): Cannot iterate over number (5)
FastMCP also logs the caught tool error to stderr (a line like Error calling tool 'jq_filter'); that is expected and separate from the stdout above.
Step 6: Test the mapping and the safety rules
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autoruns the async tests without a marker.tests/__init__.pymakestests/a package so the project root lands onsys.pathandfrom server import mcpresolves.
Add the code: tests/test_server.py
"""Tests for the jq-wrapping MCP server.
Two layers:
- the `runner` module, where the subprocess safety rules live (no shell,
timeouts, error normalization);
- the MCP server, where the two tools and the version resource are exposed.
"""
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from runner import CLIError, run_jq
from server import mcp
SAMPLE = '{"name": "Ada", "langs": ["ada", "python"], "active": true}'
# --- runner-level tests -------------------------------------------------------
def test_compact_and_raw_flags_map_to_options():
assert run_jq(".langs", SAMPLE, compact=True) == '["ada","python"]\n'
assert run_jq(".name", SAMPLE, raw=True) == "Ada\n"
def test_sort_keys_flag():
assert run_jq(".", '{"b":2,"a":1}', compact=True, sort_keys=True) == '{"a":1,"b":2}\n'
def test_nonzero_exit_becomes_clierror():
with pytest.raises(CLIError) as exc:
run_jq(".[]", "5") # cannot iterate over a number
assert "jq failed" in str(exc.value)
def test_filter_metacharacters_are_not_shell_interpreted(tmp_path):
"""A filter containing shell syntax is passed to jq as data, not a command."""
marker = tmp_path / "pwned"
with pytest.raises(CLIError):
run_jq(f". ; touch {marker}", "{}")
assert not marker.exists()
def test_timeout_is_enforced():
with pytest.raises(CLIError) as exc:
run_jq("while(true; .)", "0", timeout=0.5)
assert "timed out" in str(exc.value)
# --- server-level tests -------------------------------------------------------
async def test_tools_are_listed_and_read_only():
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
assert set(tools) == {"jq_filter", "jq_keys"}
assert tools["jq_filter"].annotations.readOnlyHint is True
async def test_jq_filter_compact():
async with Client(mcp) as client:
r = await client.call_tool(
"jq_filter", {"filter_expr": ".langs", "json_input": SAMPLE, "compact": True}
)
assert r.data == '["ada","python"]\n'
async def test_jq_keys_is_sorted_and_compact():
async with Client(mcp) as client:
r = await client.call_tool("jq_keys", {"json_input": SAMPLE})
assert r.data == '["active","langs","name"]\n'
async def test_jq_keys_has_no_filter_argument():
"""The constrained tool exposes only json_input, never a free-form filter."""
async with Client(mcp) as client:
tools = {t.name: t for t in await client.list_tools()}
assert set(tools["jq_keys"].inputSchema["properties"]) == {"json_input"}
async def test_bad_filter_surfaces_as_tool_error():
async with Client(mcp) as client:
with pytest.raises(ToolError):
await client.call_tool(
"jq_filter", {"filter_expr": ".[]", "json_input": "5"}
)
async def test_version_resource_reports_jq():
async with Client(mcp) as client:
text = (await client.read_resource("jq://version"))[0].text
assert text.startswith("jq-")
Detailed breakdown
test_filter_metacharacters_are_not_shell_interpretedis the security test. It passes a filter that would create a file if a shell ran it, then asserts the file does not exist — proving the argument list, not a shell, executed jq.test_timeout_is_enforcedfeeds jq an infinite program and a short timeout, confirming the runner cuts it off instead of hanging.test_jq_keys_has_no_filter_argumentpins the constraint: the narrow tool’s input schema has exactly one property, so no arbitrary filter can reach jq through it.- The runner tests call
run_jqdirectly; the server tests go through the MCPClient, so both the subprocess layer and the tool layer are covered.
Step 7: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
demo: ## Wrap the jq CLI and call each tool
uv run python client.py
serve: ## Run the jq-tools server over stdio (for a real MCP client)
uv run python server.py
test: ## Run the test suite
uv run pytest -q
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
Step 8: Run everything
make # prints the help screen
make demo # wraps jq and calls each tool (output in Step 5)
make test # runs the suite
The suite passes:
........... [100%]
11 passed in 0.83s
Notes on wrapping other CLIs
- Subcommands map to tools. jq is flag-driven, so its options became typed
parameters. For a subcommand-style tool (
git log,docker ps,kubectl get), give each subcommand its own tool with its own typed inputs, exactly the wayjq_filterandjq_keysare separate tools here. - Prefer an allow-list. Do not expose a generic “run any argument” tool. Model
the specific operations you want, and constrain their inputs (as
jq_keysdoes). - Keep the subprocess in one module. One
runner.pythat every tool goes through means the no-shell, timeout, and output-cap rules are written and tested once. - Resolve the binary explicitly.
shutil.whichup front turns a missing tool into a clear message. For extra safety, pin an absolute path soPATHchanges cannot swap the binary. - Return exit codes as errors, not silence. A non-zero exit should raise, with the tool’s stderr attached, so the model can correct its input.
Troubleshooting
'jq' is not installed or not on PATH. Install it withbrew install jq, or pointJQat an absolute path. A GUI-launched MCP host may have a narrowerPATHthan your shell.- A filter with a
;or|“does nothing” / errors as a syntax error. That is correct — it reached jq as a filter, not a shell. There is no shell to run it. - A tool call hangs. Some jq programs loop forever; the
timeoutbounds them. LowerDEFAULT_TIMEOUTif your inputs should always be fast. AttributeErroronr.data. Older FastMCP exposes the string onr.content[0].text;r.dataholds the typed return value on current versions.ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.
Recap
One binary became two MCP tools with no reimplementation: jq_filter maps jq’s
flags to typed parameters, and jq_keys exposes a single constrained operation. A
single runner.py holds every subprocess safety rule — argument lists instead of a
shell, JSON on stdin, a timeout, an output cap, and normalized errors — and the
test suite proves the important ones, including that shell metacharacters in a
filter never execute. The same shape wraps any CLI you already trust.
Next improvements:
- Register the server over stdio with
make serveand add it to Claude Desktop or Claude Code. - Wrap a subcommand-style tool (git, docker) with one tool per subcommand.
- Add a per-call byte budget on stdin as well as stdout for tools that accept large documents.