Part 2 of the Local-First AI Agents series — patching docker/mcp-gateway so it actually works with local, persistent MCP servers.
When the documentation lies
Docker MCP Gateway has a feature that sounds perfect for homelab use: a config.yaml file where you can override per-server settings like volume mounts, user IDs, environment variables, and the longLived flag. The documentation says it works. The examples show it working.
It doesn’t work.
Not because of a typo or a misconfiguration, but because the gateway’s merge logic has a fundamental flaw: when the catalog already has a value for a field, your config override is ignored. And for most servers, the catalog has empty values — empty slices, not nil values — which Go treats as “already set.”
This post covers three bugs I found, how I diagnosed them, how I patched them, and what’s changed upstream since. If you’re running MCP servers with Docker, you’ll hit at least one of these.
Bug #1: Config overrides are silently ignored
The problem
Docker MCP Gateway reads server definitions from two sources:
- Catalog (remote YAML from Docker Hub): the canonical server definitions
- Config (local
config.yaml): your overrides for volumes, user, env, longLived
The intended behavior: catalog provides defaults, config overrides them. The actual behavior: catalog values always win, even when they’re empty.
Here’s why. The gateway’s argsAndEnv() function in clientpool.go builds Docker run arguments for each server. For volumes, it checks:
// Original code (broken)
if len(serverConfig.Spec.Volumes) > 0 {
// mount volumes from catalog
}
If the catalog has volumes: [] (empty slice, which is the default), len() is 0, and the volume block is skipped entirely. Your config override is never read.
The same pattern applies to:
- User (
-uflag): skipped ifSpec.Useris empty string - Env: skipped if
Spec.Envis empty slice - LongLived: skipped if
Spec.LongLivedis false (the default)
This means the config override system is effectively non-functional for any server where the catalog doesn’t already specify these values. Which is most servers.
The impact
For my homelab, this meant:
- Paper downloads vanished: The
paper-searchserver writes PDFs to/app/downloads/inside the container. Without a volume mount, those files disappear when the container exits. - Permission errors: Containers ran as root, creating files owned by root on host volumes.
- Environment variables ignored: API keys and config flags specified in
config.yamlwere never passed to containers. - Containers died immediately: Without
longLived: true, ephemeral containers were created and destroyed for every request, with no state persistence.
Diagnosing the bug
The first clue was the gateway logs. When I specified -v /workspace/paper-search:/app/downloads:rw in config.yaml, the container started without that mount:
Running mcp/paper-search with [run --rm -i --security-opt no-new-privileges ...]
No -v flag. No -u flag. No -e flag. The config was being read (I could see it in the logs), but not applied.
I cloned the gateway source and traced the execution path:
git clone https://github.com/docker/mcp-gateway.git /tmp/mcp-gateway
cd /tmp/mcp-gateway
grep -rn "Spec.Volumes" pkg/gateway/
The root cause was clear: the merge logic checks if the catalog value exists, not if it’s meaningful. An empty slice [] is a valid value that prevents overrides from being applied.
The patch
I added helper functions to clientpool.go:
serverConfigValues() — Extracts per-server config from the nested map[canonicalized_name] → config_values structure:
func serverConfigValues(sc *catalog.ServerConfig) map[string]interface{} {
if sc.Config == nil {
return nil
}
// Config values are nested under the server name
for _, v := range sc.Config {
if m, ok := v.(map[string]interface{}); ok {
return m
}
}
return nil
}
configEnvOverrides() — Handles both environment variable formats (catalog-style [{name, value}] and shell-style [KEY=VALUE]):
func configEnvOverrides(cfg map[string]interface{}) []struct{Name, Value string} {
// Check env: [{name: KEY, value: VALUE}] (catalog format)
// Check environment: [KEY=VALUE] (shell format, more ergonomic)
}
The merge logic — Apply config overrides when catalog Spec fields are empty:
// Volumes: apply config overrides when Spec.Volumes is empty
if len(serverConfig.Spec.Volumes) == 0 {
if volumeOverrides, ok := cfg["volumes"]; ok {
for _, v := range volumeOverrides.([]interface{}) {
if vol, ok := v.(string); ok && isSafeFlagValue(vol) {
args = append(args, "-v", vol)
}
}
}
}
// User: apply config override when Spec.User is empty
if serverConfig.Spec.User == "" {
if userOverride, ok := cfg["user"]; ok {
if u, ok := userOverride.(string); ok && isSafeFlagValue(u) {
args = append(args, "-u", u)
}
}
}
The design principle: catalog values always take priority over config overrides. This is safe — if the catalog says “run as user 1000”, that choice is respected. The override only applies when the catalog has no opinion.
Testing the patch
I added 11 new test cases covering:
- Volume overrides from config when catalog is empty
- Volume overrides ignored when catalog has values (catalog wins)
- User overrides from config
- Env overrides in both formats
- LongLived overrides
- Safety checks (flag injection prevention)
All 45 tests pass (34 existing + 11 new):
cd /tmp/mcp-gateway
go test ./pkg/gateway/ -v -run TestConfigOverride
=== RUN TestConfigOverrideVolumes
=== RUN TestConfigOverrideUser
=== RUN TestConfigOverrideEnv
=== RUN TestConfigOverrideEnvShellFormat
=== RUN TestConfigOverrideLongLived
=== RUN TestConfigOverrideCatalogWins
=== RUN TestConfigOverrideSafetyCheck
...
--- PASS: TestConfigOverrideVolumes (0.00s)
--- PASS: TestConfigOverrideUser (0.00s)
--- PASS: TestConfigOverrideEnv (0.00s)
--- PASS: TestConfigOverrideEnvShellFormat (0.00s)
--- PASS: TestConfigOverrideLongLived (0.00s)
--- PASS: TestConfigOverrideCatalogWins (0.00s)
--- PASS: TestConfigOverrideSafetyCheck (0.00s)
ok github.com/docker/mcp-gateway/pkg/gateway 2.184s
Building the patched image
cd /tmp/mcp-gateway
docker build -t docker/mcp-gateway:config-override .
Then in compose.yaml:
services:
gateway:
image: docker/mcp-gateway:config-override
End-to-end verification:
# Start the gateway
docker compose up -d gateway
# Check that paper-search gets the volume mount
docker logs mcp-gateway 2>&1 | grep "paper-search"
# - Running mcp/paper-search with [... -v /workspace/paper-search:/app/downloads:rw -u 1000:988]
# ✓ Volume mount applied!
# ✓ User ID applied!
# ✓ Environment variable applied!
Bug #2: tools.yaml filtering is broken with —servers
The problem
The MCP Gateway supports a tools.yaml file that restricts which tools each server exposes. You can say “paper-search should only expose search_papers and download_paper” to keep your context window lean.
This works when using dynamic tool registration (the default). But when you specify servers with the --servers flag, the filtering stops working. All tools from all servers are exposed, regardless of your tools.yaml.
The root cause
In capabilitites.go, the isToolEnabled() function has two branches:
func isToolEnabled(configuration Configuration, serverName, serverImage, toolName string, enabledTools []string) bool {
if len(enabledTools) == 0 {
// Branch 1: No dynamic tools, check tools.yaml
tools, exists := configuration.tools.ServerTools[serverName]
if !exists {
return true // No filter, allow all
}
return slices.Contains(tools, toolName) // Filter by tools.yaml
}
// Branch 2: Dynamic tools exist, check against them
for _, enabled := range enabledTools {
if enabled == "*" || enabled == toolName || ... {
return true
}
}
return false
}
The enabledTools parameter is g.ToolNames() — a list of all registered tool names. When --servers is used, all server tools are registered at startup, making enabledTools non-empty. This means Branch 2 is always taken, and the tools.yaml filter in Branch 1 is never reached.
The startup log confirms this:
Note: dynamic-tools disabled when using --servers flag
But the code still populates g.ToolNames() with all registered tools, bypassing the filter.
The workaround
Until this is fixed upstream, I control tool exposure by limiting which servers are in the --servers flag. I defined four tiers:
| Tier | Servers | Tools | Context Cost |
|---|---|---|---|
| Core | paper-search, fetch, time, sequentialthinking | ~5 | ~200 tokens |
| Standard | +wikipedia-mcp, duckduckgo, playwright, markdownify | ~24 | ~1,000 tokens |
| Extended | +markitdown, hackernews, task-orchestrator, code-interpreter | ~37 | ~1,500 tokens |
| Full | All 12 servers | ~61 | ~2,500 tokens |
For a 16k context model, Standard is the sweet spot. For 128k models, go Full.
The tools.yaml is still maintained as documentation — it lists every tool for every server with descriptions. When the upstream fix lands, uncommenting a tool will be all it takes to enable it.
# playwright: Full browser control. 16 tools total.
playwright:
- browser_navigate
- browser_take_screenshot
- browser_snapshot
- browser_network_requests
- browser_evaluate
- browser_close
# - browser_click # uncomment to enable
# - browser_type # uncomment to enable
# - browser_fill_form # uncomment to enable
Bug #3: The colon separator
The problem
The MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS environment variable controls which host paths containers can mount writable. The documentation says nothing about the separator character.
The discovery
I tried commas (the obvious choice), spaces, and semicolons. None worked. Volume mounts were rejected with “path not in allowed list” errors.
By reading the source code, I found:
// In the gateway's path validation:
allowedPaths := strings.Split(os.Getenv("MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS"), ":")
The separator is : (colon). On Linux, where paths already use : in URLs, this is confusing. But it makes sense — : is Go’s filepath.SplitList() separator on Linux, consistent with PATH and other path lists.
The fix
In compose.yaml:
environment:
- 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
Not commas. Not spaces. Colons. This is undocumented and tripped me up for hours.
The upstream picture (revisited October 2026)
I built all this against the gateway in late August 2026. Upstream has since moved (v0.44.x as of publishing), so before you assume these bites still exist, re-test against the current release — the merge logic may have improved, and some surrounding behavior has definitely changed. What I can say from my side:
- Config override patch (Bug #1): My patch — clean, well-tested, and following the “catalog wins” safety principle — is kept alongside my local gateway working copy. No equivalent fix had landed upstream as of writing.
- Tools filter bug (Bug #2): Still worth checking latest. The fix would be to check
configuration.tools.ServerToolseven whenenabledToolsis non-empty, as an additional allowlist filter. - Colon separator (Bug #3): A documentation issue — the variable should document its separator.
These bugs are not edge cases. Anyone running MCP servers with Docker Compose and persistent data will hit them — and I’ll point at exactly what to grep.
Lessons from patching open source
-
Read the source before assuming you’re wrong. When config overrides didn’t work, I spent hours tweaking YAML syntax before cloning the repo. One
greprevealed the bug in minutes. -
Write tests first. The 11 test cases caught edge cases I wouldn’t have thought of (flag injection, catalog-wins semantics, empty string vs nil).
-
Build locally before deploying. The patched Docker image was built and tested in minutes. End-to-end verification with actual containers confirmed the fix.
-
Document your patches. My local patch folder contains the source diff, test results, and a README explaining each change. If upstream accepts an equivalent fix, I can drop my custom image.
-
Work around what you can’t fix. The tools.yaml bug can’t be patched without modifying the gateway’s capability registration flow. The tier system is a pragmatic workaround.