A drop-anywhere multi-agent + MCP system

Plug-and-play Agents & MCP

Copy the directory to any Linux host and it runs unchanged: a bundled model gateway, a transport-agnostic MCP tools server, and three configured agents behind an orchestrator, all wired together by a config loader that validates every value before anything starts. Only two files ever differ between stations.

01

"It works on my machine" is a config problem

A multi-agent setup usually accumulates a hardcoded IP here, an absolute path there, a model ID baked into a prompt file, until moving it to a second server means hand-editing half the repo. The brief for this one was blunt: it has to run unchanged on any Linux host, with only env/<station>.yaml and environment variables allowed to differ.

No hardcoded values

No IPs, absolute paths, API keys, model IDs, or ports anywhere in the pack. Every one is ${VAR} or ${VAR:default}.

Transport-agnostic MCP

One server binary. stdio for a local subprocess, streamable HTTP for a containerized one, chosen by a single env var.

Per-agent models

Each agent names a gateway alias, never a provider model. Swapping GPT-4o for Claude Sonnet is a gateway edit, not an agent edit.

02

Four layers, generated together

Everything a station needs ships inside the pack, including the model gateway. No manual pip install, no hand-edited YAML outside env/.

LiteLLM proxy

gateway/

Eight model aliases (GPT-4o, Claude Sonnet, Gemini, local Ollama, OpenRouter…) behind one OpenAI-compatible endpoint. A validating entrypoint drops any alias whose credential is missing, at boot, with a warning, not a crash.

Docker multi-stage Non-root
FastMCP server

mcp/tools-server/

search_web, get_weather, run_shell_safe. Pydantic-validated inputs, and a tool call never raises: every failure comes back as a structured {ok: false, error: {...}} instead of killing the session.

stdio + HTTP No shell=True
researcher / coder / reviewer

agents/ + prompts/

Each agent is a YAML file: a model alias, a tool allowlist, iteration limits, and a JSON output contract enforced by the orchestrator. The reviewer runs on the cheapest alias, at temperature 0, and gets no tools at all — it judges what it's handed, nothing more.

<400-token prompts JSON contracts
FastAPI

orchestrator/ + config_loader.py

Loads the pack, exposes /health, /agents, /run, and runs the tool-use loop against whichever MCP transport the station configured, over the same code path either way.

Fail-fast config Secret redaction
03

A config loader that reports everything at once

config_loader.py merges config/base.yamlenv/<station>.yaml → the environment, substitutes every placeholder, loads each agent, and validates the result against a JSON Schema. Click a stage below.

04

See it flow

A scripted, client-side recreation of one /run call, matching the sequence diagram below beat for beat. It loops through a hardcoded sequence to give a feel for a real tool-use round trip.

Demo replay, not a live feed

Caller
orchestrator
tools-server
gateway
provider

Trace

    +

    Not a mockup: a live deploy

    The pack's own scripts/deploy.sh runs a preflight check, brings up all three containers, polls every health endpoint, then prints exactly this — captured from a real local deploy.

    $ ./scripts/deploy.sh local

    4. agent wiring
           agent       model alias    contract  tools                   status
           ----------  -------------  --------  ----------------------  ------
           coder       gpt-4o         json      run_shell_safe          ready
           researcher  claude-sonnet  json      search_web,get_weather  ready
           reviewer    claude-haiku   json      -                       ready
    
           gateway      http://gateway:4000  [ok]
           mcp tools-server http   http://tools-server:8081/mcp
    
    deployed
      ok   station 'local' is up

    Every service runs read-only-root, non-root, with the pack itself bind-mounted read-only into every container. 63 tests cover the config loader: merge precedence, every ${VAR:default} form, cross-reference checks (an agent naming a tool no server exposes fails the load), and that every problem in a run is reported together, never one at a time.

    06

    Diagrams

    One request through the whole stack, and the same system at rest.

    Sequence: one agent turn

    The orchestrator never knows or cares whether the MCP session is a subprocess or an HTTP connection — same code path either way. The same steps drive the scripted trace above.

    sequenceDiagram
        actor Caller
        participant Orch as orchestrator (/run)
        participant MCP as tools-server (stdio or HTTP)
        participant GW as gateway (LiteLLM)
        participant LLM as Provider (alias-resolved)
    
        Caller->>Orch: POST /run {agent, input}
        Orch->>MCP: list_tools() for this agent's allowlist
        MCP-->>Orch: tool schemas
        Orch->>GW: chat completion + tool schemas
        GW->>LLM: resolved alias -> provider model
        LLM-->>GW: tool_use: search_web(...)
        GW-->>Orch: tool call requested
    
        Orch->>MCP: call_tool(search_web, args)
        MCP-->>Orch: {ok, data, error}
        Orch->>GW: tool result appended
        GW->>LLM: continue
        LLM-->>GW: final JSON answer
        GW-->>Orch: response
        Orch-->>Caller: contract-checked JSON
            

    Architecture: at rest

    Three containers, each read-only-root, sharing one read-only mount of the pack.

    flowchart LR
        subgraph Pack["Pack (read-only mount: /pack)"]
            Cfg["config_loader.py\nconfig/ + env/ + agents/"]
        end
    
        Cfg -. validates .-> Orch["orchestrator\nFastAPI"]
        Cfg -. validates .-> GW["gateway\nLiteLLM proxy"]
        Cfg -. validates .-> MCP["tools-server\nFastMCP"]
    
        Orch <-->|stdio or HTTP| MCP
        Orch -->|OpenAI-compatible| GW
    
        GW --> OpenAI[("OpenAI")]
        GW --> Anthropic[("Anthropic")]
        GW --> Gemini[("Gemini")]
        GW --> Ollama[("local Ollama")]
        GW --> OpenRouter[("OpenRouter")]