Patching the Gateway: When Open Source Doesn't Quite Work

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:

  1. Catalog (remote YAML from Docker Hub): the canonical server definitions
  2. 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 (-u flag): skipped if Spec.User is empty string
  • Env: skipped if Spec.Env is empty slice
  • LongLived: skipped if Spec.LongLived is 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-search server 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.yaml were 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:

TierServersToolsContext Cost
Corepaper-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
FullAll 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.ServerTools even when enabledTools is 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

  1. 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 grep revealed the bug in minutes.

  2. 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).

  3. Build locally before deploying. The patched Docker image was built and tested in minutes. End-to-end verification with actual containers confirmed the fix.

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

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

Next: Homelab Setup — 12 MCP servers in Docker Compose