Skip to content

Proxy daemon does not register MCP servers for runs with mcp[].auth.grant (post-#338 regression in 0.5.1) #348

@PatrickSpieker

Description

@PatrickSpieker

Summary

Following up on #335, which was fixed by #338. With Moat 0.5.1 (built 2026-04-28, includes #338), the
original failure mode is gone — credential loading no longer 500s — but a new failure replaces it: the
proxy daemon never registers runs that declare MCP servers, so the relay endpoint serves 404 for every
request.

The agent's ~/.claude.json looks correct, the run metadata correctly includes the auto-included mcp-*
grants from appendMCPGrants(), but ~/.moat/proxy/runs.json is never updated to include the new run,
and the relay can't route to it.

This appears to be a regression in the run-registration path, distinct from the credential-loading path
#338 fixed. Symptoms are similar enough to #335 that it could easily be mistaken for the same bug, but
the error wording and diagnostic evidence are different.

Environment

$ moat version
moat 0.5.1
commit: fc0596858df7275a341e3ca195860cd773c4e564
built:  2026-04-28T22:14:27Z
go:     go1.25.5

Runtime: apple containers (macOS, arm64)

Reproduces with both Render MCP (mcp.render.com/mcp) and Linear MCP (mcp.linear.app/mcp) — same as
the original #335 repro patient.

Steps to reproduce

  1. Store credentials for both MCP servers (one-time):
moat grant mcp render
moat grant mcp linear
  1. Use this moat.yaml (minimal — adapted from a real project's config; the runtime: apple and
    Playwright bits aren't load-bearing for this bug):
runtime: apple
dependencies:
  - python
grants:
  - claude

mcp:
  - name: render
    url: https://mcp.render.com/mcp
    auth:
      grant: mcp-render
      header: Authorization
  - name: linear
    url: https://mcp.linear.app/mcp
    auth:
      grant: mcp-linear
      header: Authorization
  1. Start a fresh run:
moat claude .
  1. From inside the container, inspect the generated relay config:
python3 -c '
import json
cfg = json.load(open("/home/moatuser/.claude.json"))
for name in ("render", "linear"):
    print(name, json.dumps(cfg["mcpServers"][name]))
'

Output (looks correct):

render {"type": "http", "url": "http://192.168.64.1:19080/mcp/<TOKEN>/render", "headers":
{"Authorization": "moat-stub-mcp-render"}}
linear {"type": "http", "url": "http://192.168.64.1:19080/mcp/<TOKEN>/linear", "headers":
{"Authorization": "moat-stub-mcp-linear"}}
  1. Probe the relay — same approach as in the original Top-level Remote mcp: failing to load static mcp-* credentials in Apple container #335 repro:
python3 - <<'PY'
import json, subprocess
cfg = json.load(open("/home/moatuser/.claude.json"))
for name in ("render", "linear"):
    s = cfg["mcpServers"][name]
    res = subprocess.run([
        "curl", "-sS", "-D", "-", "-o", "/tmp/body.txt",
        "-X", "POST", s["url"],
        "-H", "Authorization: " + s["headers"]["Authorization"],
        "-H", "Content-Type: application/json",
        "-H", "Accept: application/json, text/event-stream",
        "--data", json.dumps({
            "jsonrpc": "2.0", "id": 1, "method": "initialize",
            "params": {"protocolVersion": "2024-11-05", "capabilities": {},
                       "clientInfo": {"name": "probe", "version": "0"}},
        }),
    ], text=True, capture_output=True)
    print(f"=== {name} ===")
    print(res.stdout)
    print("BODY:", open("/tmp/body.txt").read()[:600])
PY

Actual result

=== render ===
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 29 Apr 2026 19:34:27 GMT
Content-Length: 139

BODY: MOAT: MCP server '<TOKEN>' not configured. Available servers: 2. Check moat.yaml.

=== linear ===
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 139

BODY: MOAT: MCP server '<TOKEN>' not configured. Available servers: 2. Check moat.yaml.

The relay handler appears to be parsing the first path segment (<TOKEN>) as the server name, rather
than treating it as the per-run auth token and using the second segment (render / linear) as the
server name. The "Available servers: 2" hint suggests the daemon does know there are two configured MCP
servers, but is looking them up by the wrong key.

Expected result

HTTP/1.1 200 OK (or 202) with the upstream MCP server's initialize response — same as the
documented contract for the relay.

Why this is distinct from #335

Symptoms are similar (relay errors, MCP unusable from the agent), but the root cause is at a different
layer:

#335 (pre-#338) This issue (0.5.1, post-#338)
Status code 500 Internal Server Error 404 Not Found
Error wording `MOAT: Failed to load credential for 'render'. Grant: mcp-render. Run: moat grant mcp
render` MOAT: MCP server '<auth-token>' not configured. Available servers: 2. Check moat.yaml.
Server name in error Correct ('render') The auth token ('<32-byte hex>')
~/.moat/proxy/runs.json Run + mcp_servers registered Run never appears at all
Step that fails Credential lookup for known server Server lookup itself; run-registration with
proxy daemon

#338 fixed the credential-loading half of the path (appendMCPGrants correctly merges mcp[].auth.grant
into the run context). This issue is one step earlier in the stack — the proxy daemon's per-run
registration of MCP server endpoints.

Diagnostic evidence

1. Run metadata is correct — appendMCPGrants did its job

$ python3 -c "import json; m = json.load(open('/Users/me/.moat/runs/run_9906fd47dc76/metadata.json'));
print(m['grants'])"
['claude', 'github', 'ssh:github.com', 'mcp-render', 'mcp-linear']

Both mcp-render and mcp-linear are present — they were added by #338's auto-include behavior, exactly
as designed. So the bug is not in the run-context layer.

2. Proxy daemon's runs.json does not contain the new run

After running moat claude . and observing the 404 above, ~/.moat/proxy/runs.json (file mtime updated
to the same minute as the run) contains only 4 unrelated old runs (none of them this run, none of
them with any mcp_servers key):

{
"version": 1,
"runs": [
  { "auth_token": "8c318ee0…", "run_id": "run_5d11fba44e37", "grants": ["claude", "github"], },
  { "auth_token": "d4331ab5…", "run_id": "run_b9bb27801818", "grants": ["claude"], },
  { "auth_token": "965d1683…", "run_id": "run_35698067493b", "grants": ["claude"], },
  { "auth_token": "d0605162…", "run_id": "run_ca83d19109c5", "grants": ["claude"], }
]
}

The new run's auth token (the <TOKEN> shown by the relay's 404 message and visible in ~/.claude.json
inside the container) is not in this list. None of the four entries have any mcp_servers field. The
daemon is actively writing to this file (mtime updates on each run start) but new runs with MCP grants
are not being added.

3. Restarting the daemon does not help

$ pgrep -af "moat _daemon"
88204 /opt/homebrew/bin/moat _daemon --dir /Users/me/.moat/proxy --proxy-port 19080
$ kill 88204
$ moat claude .   # respawns the daemon and starts a fresh run
# … run a new probe …
# Same 404, with a different <TOKEN> from the new run, still not in runs.json

The daemon respawns cleanly; new runs trigger a runs.json write at the same timestamp; but the new run
is never present in the file. The four old entries are simply rewritten unchanged.

4. Working comparison: prepackaged sandbox-local stdio MCP servers are unaffected

claude.mcp.<name> stdio servers (e.g., playwright) work correctly, since they don't go through the
relay. The break is isolated to the top-level remote mcp: registration path.

5. There is no daemon.log output

~/.moat/proxy/daemon.log is zero bytes (it has been since first install per its mtime), so there's no
daemon-side error trace I can include.

Things I tried

  • Re-running moat grant mcp render / moat grant mcp linear — no change. moat grant show confirms
    both creds are encrypted and on disk.
  • Killing moat _daemon and letting it respawn — no change. Fresh daemon, same omission.
  • Running multiple fresh moat claude . sessions back-to-back — every run produces a new auth token,
    none of them appear in runs.json.
  • Verifying the new run does receive the MCP grants in its run metadata (point 1 above) — it does, so
    the issue is downstream of appendMCPGrants.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions