Homelab Setup: 12 MCP Servers in Docker Compose

Part 3 of the Local-First AI Agents series — the full configuration, from host directory layout to per-server overrides.

The architecture

Before diving into config files, let’s understand the architecture. Every MCP server runs as an isolated Docker container, managed by the gateway. The gateway itself is a thin routing layer that:

  1. Receives MCP protocol requests from AI clients
  2. Spins up (or reuses) the appropriate server container
  3. Routes the tool call to that container
  4. Returns the result to the client

Data persistence comes from volume mounts: each server writes to a directory on the host filesystem, mounted inside the container. When the container exits, the data stays.

Host filesystem                     Docker containers
~/data.mcp/                         (ephemeral, --rm)
├── papers/                         paper-search → /app/downloads/
├── playwright/                     playwright   → /tmp/playwright-output/
│   ├── output/
│   ├── userdata/
│   └── storage/
├── memory/                         memory       → /app/data/
├── sqlite/                         sqlite       → /app/data/
├── markdownify/                    markdownify  → /workspace/markdownify/
├── files/                          filesystem   → /projects/files/
└── ...
         ▲                              ▲
         │        volume mounts         │
         └──────────────────────────────┘

The gateway sits between your LLM and these containers, translating between the OpenAI-compatible API (that most local LLMs speak) and the MCP protocol (that the servers speak).

Directory layout

Create the host directories first:

mkdir -p ~/data.mcp/{papers,paper-search,files,memory,sqlite,playwright/{output,userdata,storage},markdownify,markitdown,task-orchestrator,code-interpreter,hackernews,desktop-commander,duckduckgo}

The gateway mounts the entire data.mcp/ directory into its container at /workspace/:

# In compose.yaml
volumes:
  - ~/data.mcp:/workspace

This is critical: volume paths in config.yaml must use the gateway’s perspective (/workspace/), not the host perspective (~/data.mcp/). The gateway validates volume paths against its own filesystem, not the host’s.

compose.yaml

This is the heart of the setup. Let’s walk through it section by section.

services:
  gateway:
    image: docker/mcp-gateway:config-override
    container_name: mcp-gateway
    restart: unless-stopped
    user: "1000:988"
    networks:
      - mcp-net
    ports:
      - "8811:8811"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ~/.docker/mcp:/mcp
      - ~/data.mcp:/workspace
    environment:
      # Local-only default; set a real token if the gateway is ever
      # reachable beyond localhost
      - MCP_GATEWAY_AUTH_TOKEN=no-auth-token
      - MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS=/workspace/paper-search:/workspace/papers:/workspace/files:/workspace/memory:/workspace/sqlite:/workspace/playwright/output:/workspace/playwright/userdata:/workspace/playwright/storage:/workspace/markdownify:/workspace/markitdown:/workspace/task-orchestrator:/workspace/code-interpreter:/workspace/hackernews:/workspace/desktop-commander:/workspace/duckduckgo
    command:
      - --transport=streaming
      - --allow-unauthenticated   # localhost only — same caveat
      - --port=8811
      - --verbose
      - --config=/mcp/config.yaml
      - --tools-config=/mcp/tools.yaml
      - --secrets=/run/secrets/mcp_secrets
      - --verify-signatures=false
      - --watch=true
      - --servers=paper-search,fetch,time,sequentialthinking,wikipedia-mcp,duckduckgo,playwright,markdownify
    secrets:
      - mcp_secrets
    healthcheck:
      test: ["CMD", "wget", "-O-", "-q", "http://localhost:8811/health"]
      interval: 60s
      timeout: 5s
      retries: 10
      start_period: 30s

networks:
  mcp-net:
    driver: bridge

secrets:
  mcp_secrets:
    file: ./secrets/mcp_secrets.env

Key points:

image: docker/mcp-gateway:config-override — My patched image (see post 2). Without the patch, config overrides for volumes, user, and env don’t work. The image is ~38MB built once and reused; it can also be exported as a tarball for machines with no build tooling:

docker load < mcp-gateway-config-override.tar.gz

/var/run/docker.sock — The gateway needs Docker socket access to create and manage server containers.

~/.docker/mcp:/mcp — Configuration directory. Contains config.yaml, tools.yaml, and catalogs/.

~/data.mcp:/workspace — Persistent data directory. All server volumes mount from here.

MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS — Colon-separated list of writable paths (inside the gateway container). Every :rw volume mount must be listed here, or the gateway rejects it. The separator is :, not commas. This is undocumented and cost me hours.

--servers=... — The tier system. Edit this line to switch between core/standard/extended/full:

# Core (5 tools, ~200 tokens):
- --servers=paper-search,fetch,time,sequentialthinking

# Standard (24 tools, ~1,000 tokens) — DEFAULT:
- --servers=paper-search,fetch,time,sequentialthinking,wikipedia-mcp,duckduckgo,playwright,markdownify

# Extended (37 tools, ~1,500 tokens):
- --servers=paper-search,fetch,time,sequentialthinking,wikipedia-mcp,duckduckgo,playwright,markdownify,markitdown,mcp-hackernews,task-orchestrator,mcp-code-interpreter

# Full (61 tools, ~2,500 tokens):
- --servers=paper-search,fetch,time,sequentialthinking,wikipedia-mcp,duckduckgo,playwright,markdownify,markitdown,mcp-hackernews,task-orchestrator,mcp-code-interpreter,memory,sqlite,filesystem,context7

--watch=true — Hot-reload. The gateway watches config.yaml and tools.yaml for changes and reloads automatically. No restart needed.

config.yaml — per-server overrides

This is where the patched gateway shines. Each server section defines volume mounts, user IDs, environment variables, and long-lived settings.

# MCP Gateway per-server configuration (homelab overrides)
#
# Volume paths use /workspace/ (gateway perspective), not ~/data.mcp/
# The Docker daemon resolves the actual host path on the host.
#
# Writable paths must be listed in MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS
# in compose.yaml (colon-separated).

filesystem:
  paths:
    - /workspace/files
    - /projects/files
    - /workspace/papers
    - /projects/papers
  user: "1000:988"
  longLived: true
  volumes:
    - /workspace/files:/projects/files
    - /workspace/papers:/projects/papers

paper-search:
  user: "1000:988"
  volumes:
    - /workspace/paper-search:/app/downloads:rw
  environment:
    - PAPER_SEARCH_MCP_UNPAYWALL_EMAIL=

playwright:
  user: "1000:988"
  longLived: true
  volumes:
    - /workspace/playwright/output:/tmp/playwright-output:rw
    - /workspace/playwright/userdata:/tmp/playwright-userdir:rw
    - /workspace/playwright/storage:/tmp/playwright-storage:rw
  environment:
    - PLAYWRIGHT_MCP_OUTPUT_DIR=/tmp/playwright-output

memory:
  longLived: true
  volumes:
    - /workspace/memory:/app/data:rw

sqlite:
  longLived: true
  volumes:
    - /workspace/sqlite:/app/data:rw

markdownify:
  paths:
    - /workspace/markdownify
  volumes:
    - /workspace/markdownify:/workspace/markdownify:rw

# Stateless servers — no volumes, no user overrides needed:
wikipedia-mcp:
sequentialthinking:
time:
fetch:
duckduckgo:

Design decisions

longLived: true for playwright, memory, and sqlite. These servers maintain state across calls. Playwright keeps browser sessions open; memory persists its knowledge graph; sqlite holds database state. Without longLived, a new container spins up for every request, losing all state.

longLived removed from paper-search. Paper-search is stateless — it downloads to a mounted volume. Each call is independent. Keeping containers alive between calls wastes resources.

user: "1000:988" — Matches the host user/group so files created by containers are writable by the host user. Without this, containers run as root and create root-owned files.

Environment variables — Both catalog-style (env: [{name: KEY, value: VALUE}]) and shell-style (environment: [KEY=VALUE]) work in the patched gateway. Shell-style is more ergonomic for config.yaml.

tools.yaml — tool filtering (and its current limitations)

This file documents every tool from every server. Currently, --servers bypasses the filtering (see Bug #2 in post 2), but the file serves as a reference and will work once the upstream fix lands.

# MCP Gateway Tools Configuration
# ================================
# Uncomment tools you need per session. Gateway hot-reloads on save.
# Servers NOT listed expose ALL tools — list a server to restrict it.
# Local LLMs (16k ctx): ~4k tokens for 61 tools ≈ 25% of context.
# Keep active tools lean. Each tool ≈ 40 tokens of context.

# ── RESEARCH & ACADEMIC ──────────────────────────────
# paper-search: Search/download papers from ArXiv, Scholar, etc.
paper-search:
  - search_papers
  - download_paper
  - read_paper

# ── KNOWLEDGE & REFERENCE ────────────────────────────
# wikipedia-mcp: 22 tools (search,summaries,articles,facts,sections).
wikipedia-mcp:
  - search_wikipedia
  - get_summary

# ── WEB SEARCH & FETCH ───────────────────────────────
duckduckgo:
  - search
fetch:
  - fetch

# ── BROWSER AUTOMATION ───────────────────────────────
# playwright: Screenshots saved to /workspace/playwright/output/
playwright:
  - browser_navigate
  - browser_take_screenshot
  - browser_snapshot
  - browser_network_requests
  - browser_evaluate
  - browser_close

# ── DOCUMENT CONVERSION ──────────────────────────────
# markdownify: PDF/docx/xlsx/pptx/web/youtube → markdown.
# Uncomment to enable:
# markdownify:
#   - pdf-to-markdown
#   - docx-to-markdown
#   - webpage-to-markdown
#   - youtube-to-markdown

# ── REASONING ────────────────────────────────────────
sequentialthinking:
  - sequentialthinking

# ── UTILITY ──────────────────────────────────────────
time:
  - get_current_time

The compact format includes inline descriptions so you can quickly see what each tool does without looking up documentation.

The sync script

Config files live in the project repo but need to be copied to ~/.docker/mcp/ where the gateway reads them. A tiny sync script automates this:

#!/bin/bash
# scripts/sync-configs.sh — Sync project configs to Docker MCP config dir
set -e
SRC="$(cd "$(dirname "$0")/.." && pwd)/configs"
DST="$HOME/.docker/mcp"

echo "Syncing project configs to $DST ..."
cp -v "$SRC/config.yaml"   "$DST/config.yaml"
cp -v "$SRC/tools.yaml"    "$DST/tools.yaml"
cp -v "$SRC/docker-mcp.yaml" "$DST/catalogs/docker-mcp.yaml"
echo "Done. Restart gateway to apply: docker compose restart gateway"

Run this after any config change:

./scripts/sync-configs.sh
docker compose restart gateway

Or just use --watch=true (already in the compose.yaml) for hot-reload on config changes.

Adding and removing servers

Adding a new server

  1. Find the server in the catalog:

    docker exec mcp-gateway grep "your-server-name" /mcp/catalogs/docker-mcp.yaml
  2. Add it to --servers in compose.yaml (comma-separated).

  3. If it needs volumes, env, or user overrides, add a section in config.yaml.

  4. If it needs writable paths, add them to MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS in compose.yaml.

  5. Create the host directory:

    mkdir -p ~/data.mcp/your-server-name
  6. Sync and restart:

    ./scripts/sync-configs.sh && docker compose up -d gateway

Removing a server

  1. Remove it from --servers in compose.yaml.

  2. Optionally remove its config section from config.yaml.

  3. Restart:

    docker compose up -d gateway

The gateway will stop creating containers for that server. Any longLived: true containers will need manual cleanup:

docker rm -f $(docker ps -q --filter "label=docker-mcp=true" --filter "label=docker-mcp-name=your-server-name")

Verifying the setup

# Check gateway health
curl -s http://localhost:8811/health

# List all active tools
python3 scripts/mcp_client.py list

# Test a specific tool
python3 scripts/mcp_client.py call time get_current_time '{"timezone": "UTC"}'

# Check server container status
docker ps --filter "label=docker-mcp=true"

# Clean up stale containers
docker rm -f $(docker ps -q --filter "label=docker-mcp=true")

The full server roster

ServerTierToolsVolumesAPI KeyPurpose
paper-searchCore1-3/workspace/paper-searchNoAcademic paper search & download
fetchCore1NoneNoHTTP URL → markdown
timeCore2NoneNoTime & timezone conversion
sequentialthinkingCore1NoneNoStep-by-step reasoning
wikipedia-mcpStandard22NoneNoWikipedia search & articles
duckduckgoStandard2NoneNoPrivate web search
playwrightStandard16/workspace/playwright/*NoBrowser automation & screenshots
markdownifyStandard11/workspace/markdownifyNoDocument → markdown conversion
markitdownExtended1/workspace/markitdownNoLightweight Microsoft converter
mcp-hackernewsExtended4NoneNoHacker News stories & search
task-orchestratorExtended40/workspace/task-orchestratorNoResearch project management
mcp-code-interpreterExtended1/workspace/code-interpreterNoPython sandbox execution
memoryFull6/workspace/memoryNoKnowledge graph persistence
sqliteFull5/workspace/sqliteNoSQL database
filesystemFull8/workspace/filesNoFile read/write operations
context7Full2NoneYesLibrary documentation lookup

Servers requiring API keys (not in the default setup): brave, tavily, exa, chroma, wolfram-alpha, perplexity-ask.

Next: Connecting Your Local LLM