Skip to content

awmg silently corrupts stdout JSON channel by writing structured logs to stdout when log-dir isn't writable #5771

@boydj

Description

@boydj

awmg silently corrupts stdout JSON channel by writing structured logs to stdout when log-dir isn't writable

Summary

gh-aw-mcpg (awmg) is documented and used by start_mcp_gateway.cjs as
emitting only the rewritten MCP configuration JSON on stdout (so the
caller can JSON.parse(stdout) to get the gateway URL/api-key/etc).

In practice, when --log-dir points at a directory awmg cannot open log
files in, awmg falls back to writing its full structured log stream to
stdout — every [INFO] [startup] ..., [DEBUG] [difc] ...,
[INFO] [backend] ... line — concatenated with (or in place of) the JSON
config. The receiving start_mcp_gateway.cjs then crashes:

[error] SyntaxError: Expected ',' or ']' after array element in JSON at position 5 (line 1 column 6)
    at JSON.parse (<anonymous>)
    at main (.../start_mcp_gateway.cjs:638:30)
[error] FATAL: Expected ',' or ']' after array element in JSON at position 5 (line 1 column 6)

The [ at position 0 of gateway-output.json is the leading [ of
[2026-05-15T20:10:17Z] [INFO] [startup] MCPG Gateway version: v0.3.6.
JSON.parse parses 2026 as the first array element and chokes on - at
position 5.

Why this matters

There is no surfaced error from awmg: the gateway appears to start, the
container exits cleanly, and the only signal the caller has is
SyntaxError: ... at position 5. We spent significant time chasing
permission/SELinux/bind-mount red herrings before instrumenting
start_mcp_gateway.cjs to dump the raw bytes of gateway-output.json and
realising those bytes were mcp-gateway.log.

Reproduction

Run on any environment where the runner is itself a container and
docker.sock is mounted from the host (sibling-DinD topology — common with
Actions Runner Controller, GitHub Enterprise self-hosted setups, etc.).

In our case:

  • Self-hosted runner: Alpine Linux v3.23 (containerized), uid 1001
  • Docker daemon: lives on the host (the runner sees /var/run/docker.sock as a bind mount only)
  • -v /tmp:/tmp:rw resolves source paths against the daemon-host's filesystem, not the runner-container's

The runner pre-creates /tmp/gh-aw/mcp-logs (uid 1001:1001) on its own
filesystem. From the gateway container, /tmp/gh-aw/mcp-logs resolves to
the daemon-host's filesystem — where the directory either doesn't exist or
is owned by a different uid. awmg cannot open mcp-gateway.log there
and falls back to stdout. Result: 26 KB of log lines on stdout instead of
the JSON config.

A minimal local reproduction:

# Make log dir non-writable by uid 1001 in the container
mkdir -p /tmp/awmg-test-logs
chmod 700 /tmp/awmg-test-logs
chown root:root /tmp/awmg-test-logs

echo '{"mcpServers":{},"gateway":{"port":8080,"domain":"localhost","apiKey":"dummy"}}' \
  | docker run --rm -i \
      --user 1001:1001 \
      -v /tmp/awmg-test-logs:/tmp/awmg-test-logs:rw \
      -v /var/run/docker.sock:/var/run/docker.sock \
      -e MCP_GATEWAY_PORT=8080 \
      -e MCP_GATEWAY_DOMAIN=localhost \
      -e MCP_GATEWAY_API_KEY=dummy \
      -e MCP_GATEWAY_LOG_DIR=/tmp/awmg-test-logs \
      -p 18080:8080 \
      ghcr.io/github/gh-aw-mcpg:v0.3.6
# Stdout will contain `[2026-...] [INFO] [startup] ...` lines
# instead of the expected `{"mcpServers": {...}}` JSON.

When the same command is run with a writable log dir, stdout contains
exactly the expected {"mcpServers": {}} (≈23 bytes) and all the
[INFO]/[DEBUG] lines go to ${log-dir}/mcp-gateway.log.

Expected behaviour

When the configured --log-dir cannot be opened, awmg should NOT corrupt
its stdout JSON channel. Acceptable alternatives, in order of preference:

  1. Fall back to stderr, not stdout. The entrypoint script
    (/app/run_containerized.sh) already writes its own [INFO]/[ERROR]
    lines to stderr; awmg's structured logger should do the same when it
    has to fall back. This is the smallest change and matches MCP convention
    (the Routine MCP server bug documented the same anti-pattern
    on the client side: stdout is reserved for protocol; logs go to stderr).

  2. Fail fast with a clear error to stderr ("cannot open log files in
    /tmp/gh-aw/mcp-logs: permission denied. Pass --log-dir or
    MCP_GATEWAY_LOG_DIR pointing at a writable directory") and exit
    non-zero. This is louder but at least the failure mode is debuggable.

  3. Add an explicit --quiet/--no-stdout-logs flag so callers that
    need the stdout config channel can guarantee log isolation regardless
    of the log-dir state.

The --log-dir help text mentions "falls back to stdout if directory
cannot be created", so the current behaviour is technically documented —
but in the containerized config-on-stdin/config-on-stdout mode this
fallback is structurally incompatible with the protocol the entrypoint
script implements.

Workaround

For anyone hitting this on a sibling-DinD runner before an upstream fix
lands: pre-create the log dir on the daemon-host's filesystem (not
the runner-container's) with the runner uid before launching awmg. We
do this with a one-shot root sibling container right before the gateway
starts:

docker run --rm \
  --user 0:0 \
  --entrypoint /bin/sh \
  -v /tmp:/tmp:rw \
  ghcr.io/github/gh-aw-mcpg:v0.3.6 \
  -c "mkdir -p /tmp/gh-aw/mcp-logs /tmp/gh-aw/mcp-payloads \
      && chown -R $(id -u):$(id -g) /tmp/gh-aw \
      && chmod -R u+rwX,g+rwX /tmp/gh-aw"

After this, the actual gateway container's awmg --log-dir /tmp/gh-aw/mcp-logs succeeds, structured logs go to the file, and stdout
contains only the expected JSON config.

Environment

  • ghcr.io/github/gh-aw-mcpg:v0.3.6
    (digest: sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c)
  • gh-aw v0.74.2 / gh-aw-actions/setup@23453ecc
  • GHES self-hosted runner, Actions Runner Controller (Alpine Linux v3.23,
    containerized), uid 1001
  • Docker daemon running on the host outside the runner-container

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions