Skip to content

Repository files navigation

multitool

CICD Go Version License: MIT

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.

When to use it

  • You want an agent to run real CLI tools (say network utilities like ping, dig, nmap, traceroute, nc, or curl) 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.

How it works

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.

Quickstart

Prerequisites

  • Go 1.26+
  • A reachable Docker daemonmultitool launches 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 api

The 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.yaml enables the optional cairn integration. Only calls that name a workspace need a running cairn; every other call works without it. To run fully standalone, set cairn.enable: false (and drop the baseURL/client block) in your config.

Configuration

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.

Connecting an agent

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.

Defining tools

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.json

RuntimeSpec 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.

Security & production notes

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.

Development

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 targets

spectool has its own toolchain (poetry install, poetry run pytest) — see spectool/README.md.

Further reading

  • DESIGN.md — architecture and design rationale.
  • spectool/README.md — authoring tools in Python.
  • cairn — the shared-workspace service the workspace override integrates with.

License

MIT.

About

A config driven MCP tool server

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages