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:
- Receives MCP protocol requests from AI clients
- Spins up (or reuses) the appropriate server container
- Routes the tool call to that container
- 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
-
Find the server in the catalog:
docker exec mcp-gateway grep "your-server-name" /mcp/catalogs/docker-mcp.yaml -
Add it to
--serversincompose.yaml(comma-separated). -
If it needs volumes, env, or user overrides, add a section in
config.yaml. -
If it needs writable paths, add them to
MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHSincompose.yaml. -
Create the host directory:
mkdir -p ~/data.mcp/your-server-name -
Sync and restart:
./scripts/sync-configs.sh && docker compose up -d gateway
Removing a server
-
Remove it from
--serversincompose.yaml. -
Optionally remove its config section from
config.yaml. -
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
| Server | Tier | Tools | Volumes | API Key | Purpose |
|---|---|---|---|---|---|
| paper-search | Core | 1-3 | /workspace/paper-search | No | Academic paper search & download |
| fetch | Core | 1 | None | No | HTTP URL → markdown |
| time | Core | 2 | None | No | Time & timezone conversion |
| sequentialthinking | Core | 1 | None | No | Step-by-step reasoning |
| wikipedia-mcp | Standard | 22 | None | No | Wikipedia search & articles |
| duckduckgo | Standard | 2 | None | No | Private web search |
| playwright | Standard | 16 | /workspace/playwright/* | No | Browser automation & screenshots |
| markdownify | Standard | 11 | /workspace/markdownify | No | Document → markdown conversion |
| markitdown | Extended | 1 | /workspace/markitdown | No | Lightweight Microsoft converter |
| mcp-hackernews | Extended | 4 | None | No | Hacker News stories & search |
| task-orchestrator | Extended | 40 | /workspace/task-orchestrator | No | Research project management |
| mcp-code-interpreter | Extended | 1 | /workspace/code-interpreter | No | Python sandbox execution |
| memory | Full | 6 | /workspace/memory | No | Knowledge graph persistence |
| sqlite | Full | 5 | /workspace/sqlite | No | SQL database |
| filesystem | Full | 8 | /workspace/files | No | File read/write operations |
| context7 | Full | 2 | None | Yes | Library documentation lookup |
Servers requiring API keys (not in the default setup): brave, tavily, exa, chroma, wolfram-alpha, perplexity-ask.