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:
-
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).
-
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.
-
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
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 bystart_mcp_gateway.cjsasemitting 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-dirpoints at a directoryawmgcannot open logfiles in,
awmgfalls back to writing its full structured log stream tostdout — every
[INFO] [startup] ...,[DEBUG] [difc] ...,[INFO] [backend] ...line — concatenated with (or in place of) the JSONconfig. The receiving
start_mcp_gateway.cjsthen crashes:The
[at position 0 ofgateway-output.jsonis the leading[of[2026-05-15T20:10:17Z] [INFO] [startup] MCPG Gateway version: v0.3.6.JSON.parse parses
2026as the first array element and chokes on-atposition 5.
Why this matters
There is no surfaced error from
awmg: the gateway appears to start, thecontainer exits cleanly, and the only signal the caller has is
SyntaxError: ... at position 5. We spent significant time chasingpermission/SELinux/bind-mount red herrings before instrumenting
start_mcp_gateway.cjsto dump the raw bytes ofgateway-output.jsonandrealising 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:
/var/run/docker.sockas a bind mount only)-v /tmp:/tmp:rwresolves source paths against the daemon-host's filesystem, not the runner-container'sThe runner pre-creates
/tmp/gh-aw/mcp-logs(uid 1001:1001) on its ownfilesystem. From the gateway container,
/tmp/gh-aw/mcp-logsresolves tothe daemon-host's filesystem — where the directory either doesn't exist or
is owned by a different uid.
awmgcannot openmcp-gateway.logthereand falls back to stdout. Result: 26 KB of log lines on stdout instead of
the JSON config.
A minimal local reproduction:
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-dircannot be opened, awmg should NOT corruptits stdout JSON channel. Acceptable alternatives, in order of preference:
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).
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.
Add an explicit
--quiet/--no-stdout-logsflag so callers thatneed the stdout config channel can guarantee log isolation regardless
of the log-dir state.
The
--log-dirhelp text mentions "falls back to stdout if directorycannot 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. Wedo this with a one-shot root sibling container right before the gateway
starts:
After this, the actual gateway container's
awmg --log-dir /tmp/gh-aw/mcp-logssucceeds, structured logs go to the file, and stdoutcontains only the expected JSON config.
Environment
ghcr.io/github/gh-aw-mcpg:v0.3.6(digest:
sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c)v0.74.2/gh-aw-actions/setup@23453ecccontainerized), uid 1001