A config driven MCP tool server. multitool exposes command-line programs to an
LLM agent over the Model Context Protocol, but
unlike a typical MCP server it is not compiled around a fixed set of tools: the
tools it offers — and the input schema of each — are read from configuration at
startup. Curate a different toolset per deployment by changing config, not code.
Every tool wraps exactly one program and runs it inside a disposable, hardened Docker container (read-only root filesystem, no network by default, dropped capabilities, non-root user, CPU/memory/PID and wall-clock caps). The agent chooses what a program does by supplying arguments; there is no shell, and nothing it passes is interpreted as one.
It is built on the official modelcontextprotocol/go-sdk
and the transport/logging helpers in goutils.
The full architecture and the reasoning behind each decision live in
DESIGN.md. This README is the short path to running one.
- You want an agent to run real CLI tools (say network utilities like
ping,dig,nmap,traceroute,nc, orcurl) without handing it a shell. - You want to define and curate that toolset by configuration, per deployment, instead of writing and maintaining a bespoke MCP server for each set.
- You want several servers side by side, each loaded with its own toolset and its own advertised identity.
Two files drive a multitool server, produced and consumed by two separate pieces:
| Piece | Role |
|---|---|
spectool (Python / Pydantic) |
Authoring-time helper. Tool arguments are authored as Pydantic models; spectool emits a JSON tool spec (name, description, input schema, container runtime, wrapper script). multitool never runs Python to serve a request. |
multitool (Go) |
The server. Loads a YAML application config (-c) and the JSON tool spec (-t), then serves the tools over MCP. |
Each tool falls into one of two categories:
| Category | The agent supplies | Runs |
|---|---|---|
| Direct | a trailing argv array |
a configured executable, exec'd directly with the agent's arguments appended |
| Constrained | structured, schema-validated arguments | a Python wrapper script that turns them into a fixed invocation |
Every container-backed tool returns the same result shape: the wrapped program's
exit_code and its combined output (stdout + stderr, in order). A non-zero exit
code is not a tool error — the tool ran; the program returned non-zero. The agent
should read the output rather than retry.
Prerequisites
- Go 1.26+
- A reachable Docker daemon —
multitoollaunches a container per tool call. - Python 3.12 + Poetry only if you plan to regenerate the tool spec (see Defining tools).
Build and run the bundled demo
make build # or: go build -o multitool .
# Serve the demo toolset (a set of network/HTTP debugging tools) on :6616
./multitool -l info -c demo/config.yaml -t demo/test_tools.json
# equivalently: make apiThe demo tools declare a container image bundling the network utilities they expose, so your Docker daemon must be able to pull it on the first tool call.
Sanity-check the tools it's serving
curl -sS http://localhost:6616/v1/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The demo
config.yamlenables the optionalcairnintegration. Only calls that name a workspace need a runningcairn; every other call works without it. To run fully standalone, setcairn.enable: false(and drop thebaseURL/clientblock) in your config.
The YAML application config (-c) is small — most fields have defaults, so a working
config only sets what differs. The essentials, as seen in demo/config.yaml:
| Key | Meaning | Default |
|---|---|---|
api.service.listenOn / appPort |
MCP API bind address / port | 0.0.0.0 / 6616 |
api.service.timeoutSecs.write |
HTTP write timeout — must exceed maxDuration so a long tool call can finish (enforced at load) |
930 |
api.apis.mcp.instanceName |
Names this instance; advertised as multitool-<instanceName> (bare multitool if unset). Charset [A-Za-z0-9_-]. |
(unset) |
api.apis.mcp.instructions |
Operator prose describing what this instance is for (folded into the MCP server instructions) | (unset) |
maxDuration |
System-wide ceiling on a single tool call, in seconds (≥30). Caps every per-tool timeout. | 900 |
metrics.service.listenOn / appPort |
Prometheus metrics server (separate from the API) | 0.0.0.0 / 3001, at /metrics |
cairn.* |
Optional shared-workspace integration (enable, baseURL, client.retry). Nothing under cairn is defaulted — enabling it means filling the whole block in. |
disabled |
Per-tool container runtime settings (image, network mode, memory limits, added
capabilities, writable tmpfs dirs, timeout and stop signal, …) do not live here —
they are baked into the JSON tool spec by spectool. See
Defining tools and DESIGN.md §8–§9.
The MCP endpoint is streamable HTTP, served at:
POST http://<host>:6616/v1/mcp
Point any MCP client that speaks streamable HTTP at that URL. For clients configured via JSON, the server entry is typically:
{
"mcpServers": {
"multitool": {
"type": "http",
"url": "http://localhost:6616/v1/mcp"
}
}
}On initialize the server advertises its name (multitool-<instanceName>) and
instructions describing what the instance is for and how results are shaped.
Health endpoints for load balancers: GET /liveness/alive and GET /liveness/ready.
Per-call runtime overrides. Tools whose author opted in expose a reserved
_runtime argument, letting the agent influence a narrow, allow-listed slice of
container construction per call — today extra_hosts (host→IP hints written to
/etc/hosts) and workspace (run the call inside a shared cairn workspace volume).
See DESIGN.md §10.
Tools are authored in Python with Pydantic and compiled to the JSON tool spec by
spectool. A minimal module:
from pydantic import BaseModel, ConfigDict, Field
from multitool_spec import RuntimeSpec, ToolCategory, tool
# Direct: the agent supplies the trailing argv; `dig` is the fixed executable.
@tool(
name="dig",
description="Perform DNS lookups.",
category=ToolCategory.DIRECT,
runtime=RuntimeSpec(image="my/nettools:1", network_mode="bridge", timeout_secs=30),
entrypoint=["/usr/bin/dig"],
)
class Dig(BaseModel):
model_config = ConfigDict(extra="forbid") # -> additionalProperties: false
# Constrained: structured args, validated by schema, handed to a Python wrapper.
@tool(
name="tcp-probe",
description="Check whether a TCP port is open.",
category=ToolCategory.CONSTRAINED,
runtime=RuntimeSpec(image="my/nettools:1", network_mode="bridge", timeout_secs=15),
script=(
"import os, json, subprocess\n"
"a = json.loads(os.environ['SCRIPT_ARG'])\n"
"subprocess.run(['/usr/bin/nc', '-zv', a['host'], str(a['port'])])\n"
),
)
class TcpProbe(BaseModel):
model_config = ConfigDict(extra="forbid")
host: str = Field(description="host to probe")
port: int = Field(ge=1, le=65535, description="TCP port")Regenerate the demo spec from a module with:
make generate MOD=path/to/your_tools.py # writes demo/test_tools.jsonRuntimeSpec exposes the per-tool sandbox knobs — image, network_mode,
mem_reservation / mem_limit, add_caps, writable_dirs (tmpfs), timeout_secs,
timeout_policy, stop_signal, remove_on_exit, and more — every one with a hardened
default. spectool/README.md is the authoritative authoring
guide.
Defense in depth, three independent layers (none a substitute for another):
- Schema constrains what the agent may ask for.
- The sandbox constrains what a request can do — no network, read-only rootfs, dropped capabilities, non-root, and CPU/memory/PID/wall-clock caps, all on by default and opened up only where a tool's author explicitly does so.
- Agent-level approval constrains whether a call runs at all.
Arguments are always passed as discrete argv / structured JSON — never assembled
into a shell string — so shell injection is eliminated by construction.
multitool is designed to sit behind a reverse proxy that terminates TLS and owns
origin/CORS policy, authentication, and admission/concurrency control. The built-in
permissive CORS is a local-development convenience, not a production origin policy, and
there is no in-app concurrency cap — N simultaneous calls launch N containers. See
DESIGN.md §8 and §9.4.
Residual risk: the sandbox limits blast radius but does not eliminate it — the remaining danger is the agent making the sandbox do harm within its granted authority (exfiltration, resource exhaustion). Grant network and capabilities per tool deliberately.
make build # lint + build
make test # unit tests (built with -tags e2e)
make lint # go fmt / vet / revive / golangci-lint
make docker # build the container image (needs a Docker daemon at runtime)
make help # list all targetsspectool has its own toolchain (poetry install, poetry run pytest) — see
spectool/README.md.
DESIGN.md— architecture and design rationale.spectool/README.md— authoring tools in Python.cairn— the shared-workspace service theworkspaceoverride integrates with.
MIT.