Skip to content

fix(deploy): correct the Google MCP wiring for newly created agents - #189

Merged
juniperbevensee merged 2 commits into
mainfrom
dev/juniperbevensee/google-mcp-deploy-correctness
Jul 30, 2026
Merged

fix(deploy): correct the Google MCP wiring for newly created agents#189
juniperbevensee merged 2 commits into
mainfrom
dev/juniperbevensee/google-mcp-deploy-correctness

Conversation

@juniperbevensee

@juniperbevensee juniperbevensee commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Scope, stated honestly up front

A newly created agent's Google MCP previously could not start. After this it starts, can authenticate, and persists its tokens — but it grants nothing until an operator adds Drive folder IDs. That limit is deliberate (see Security below), not an oversight. The original title said "make Google MCP actually work"; that oversold it.

Four defects, all silent

# Defect Symptom
1 GOOGLE_MCP_DIR probed only ~/Documents/GitHub/google-multiplayer-mcp; the repo is commonly cloned as google-mcp operator ticks "Google", gets no Google — no mount, no mcp_servers entry, no error
2 --config /opt/google/config.yaml only /opt/google/tokens is mounted → readFileSyncexit 1
3 nothing wrote a permission config anywhere no file to read even with 1 and 2 fixed
4 GOOGLE_TOKEN_DIR set nowhere server fell back to $HOME/.nimbleco-google/tokens; with HOME=/opt/data the /opt/google/tokens mount was dead weight

Plus: GOOGLE_CLIENT_ID/SECRET were never provisioned and HSM never writes ~/.nimbleco-google/config.json, so loadGlobalConfig() always threw and OAuth was impossible regardless of scoping. Now written to the agent .env (from the request, else inherited from HSM's own env) and referenced from the mcp_servers env block.

Security: why the default grants nothing

google-multiplayer-mcp's permissions.ts:

/** Get allowed folder IDs for a service. Empty array = no restriction. */

Empty folders means unrestricted. So drive: {access: write, folders: []} would hand every new agent unscoped read/write over an entire Google account. The wizard collects no folder IDs, so every service generates at access: none, with a header explaining how to scope it. A test asserts every generated service is none, not a sampled few.

Found while fixing the above

generateEnvContent wrote a wizard-supplied Brave key as BRAVE_API_KEY only. The runtime reads exclusively BRAVE_SEARCH_API_KEY (plugins/web/brave_free/provider.py, plus the auto-detect table in tools/web_tools.py); nothing reads BRAVE_API_KEY. The key was invisible to the agent — so the search_backend: brave-free pin also added here would have fallen straight through to auto-detect, inert on exactly the agents it was for. Both names are now written.

Independent audit findings, all addressed

BLOCKING — the preflight ran after side effects. The 400 sat below mkdirSync and the .env write and returned from inside the try, making the catch's cleanup unreachable. A rejected deploy left a half-created agent dir containing the resolved plaintext LLM key, then 409'd every retry of that name forever — recoverable only by rm -rf from a shell. On the Letta runtime it orphaned an already-created brain agent too. The file states the correct rule two functions above: "before a single door byte is written." The preflight now sits above every side effect, including the Letta branch.

Also fixed: unvalidated googleIdentity interpolated into YAML ("a: b" breaks the parse and exits the server — reintroducing this very bug; a newline could inject permission entries past the deny-by-default); the probe now requires node_modules as well as dist/index.js (the bundle imports bare googleapis/js-yaml, so a pruned checkout passed and exited 1 at runtime); non-absolute GOOGLE_MCP_DIR is rejected (Docker reads a relative volume source as a named volume, mounting an empty dir over the bundle); the 400 carries ok: false; and the generated OAuth runbook now passes GOOGLE_TOKEN_DIR and the client creds, without which it hit EROFS on the read-only container.

Testing

18 route-level integration tests running the real deploy logic and real fs writes against temp dirs — including that a rejected deploy leaves no dir, no docker start, no key-registry writes, and that the same name is still deployable afterwards.

Mutation-verified rather than trusted green: reverting each of the six fixes individually fails a test (preflight position → 4 failures; the other five → 1 each). 911 passed on the real tree, tsc --noEmit clean.

Note for reviewers: this repo has stale .wt-* worktree directories that vitest collects by default and which fail for unrelated reasons — run with --exclude '**/.wt-*/**'.

Not in this PR

duplicateOverlay recursively copies the agent data dir, so a duplicate inherits the source's google-permissions.yaml and its google-tokens/ — sharing Google identity and OAuth tokens between agents. Same isolation concern the code already handles for surface env vars; deserves its own change.

🤖 Generated with Claude Code

juniperbevensee and others added 2 commits July 30, 2026 12:14
A newly created agent's Google MCP could not start, for three independent
reasons. All three had to be wrong for the outage to be invisible, and all
three were.

1. GOOGLE_MCP_DIR defaulted only to ~/Documents/GitHub/google-multiplayer-mcp.
   The repo is commonly cloned as `google-mcp`, so the existsSync probe failed,
   googleMcpDir became undefined, and an operator who ticked "Google" in the
   wizard silently got no Google at all — no mount, no mcp_servers entry, no
   error. Now probes both names, and probes dist/index.js rather than the
   directory so an unbuilt checkout is caught too. When Google is enabled and
   nothing is found, the deploy now FAILS with the paths it looked at instead
   of shipping an agent whose integration does not exist.

2. The generated config pointed --config at /opt/google/config.yaml. Only
   /opt/google/tokens is bind-mounted, so that path never existed and the
   server exited 1 on readFileSync. Now points at
   /opt/data/google-permissions.yaml, inside the agent data dir mount.

3. Nothing wrote a Google permission config anywhere. HSM now generates one.

On (3), a security constraint shapes the default: in google-multiplayer-mcp an
EMPTY `folders` list means NO RESTRICTION (permissions.ts getAllowedFolders),
so emitting e.g. `drive: {access: write, folders: []}` would hand a brand-new
agent unscoped read/write over an entire Google account. The wizard collects no
folder IDs, so every service is generated at access: none, with a header
explaining that granting access requires listing folder IDs and how to run the
one-time OAuth flow. An optional googleIdentity in the request body lets an API
caller match an existing token file; it defaults to the agent slug.

Also creates the google-tokens dir at deploy time so it is owned by the
invoking user — letting Docker create the missing bind-mount source can leave
it root-owned, and the OAuth flow needs to write token JSON into it.

Separately, config.yaml now pins web.search_backend: brave-free. Leaving it
unset makes the runtime auto-detect from whichever API key happens to be
present, so the provider an agent searches with was a side effect of .env
contents rather than a decision — and it changed silently when a key was
added. Both creation paths share generateDefaultConfig, so the wizard and the
harness service both get it.

Tests: 12 new route-level integration tests running the real deploy logic and
its real fs writes against temp dirs — including that Google-off writes no
Google artifacts, and that every generated service defaults to none. Verified
non-vacuous: 9 of 12 fail against origin/main. 905 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
credentials/paths the server actually reads

BLOCKING (B1): the Google preflight ran AFTER mkdirSync(agentDataDir) and the
.env write, and returned from inside the try — so the catch's cleanup was
unreachable. Rejecting a deploy left a half-created agent dir containing the
resolved PLAINTEXT LLM key, and then 409'd every retry of that name forever;
recovery needed `rm -rf ~/.hermes-<slug>` from a shell with nothing in the UI
saying so. On the letta runtime it also orphaned an already-created brain agent,
which then 409'd by name too.

Moved the whole preflight above every side effect — above the LLM key
resolution and the letta branch, not merely above mkdir — which is the rule the
letta branch itself states ("before a single door byte is written"). A new test
asserts no dir, no docker start, no key-registry writes, and that the same name
is still deployable afterwards.

Three more from the audit, each the same class of bug this PR set out to fix —
a path or name that nothing on the other side reads:

- S4: GOOGLE_TOKEN_DIR was set nowhere, so the server fell back to
  $HOME/.nimbleco-google/tokens. With HOME=/opt/data the tokens landed
  somewhere the compose file never mounts deliberately, making the
  /opt/google/tokens mount dead weight. Now set explicitly.
- S2: GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET were never provisioned, and HSM
  never writes ~/.nimbleco-google/config.json, so loadGlobalConfig() always
  threw and the OAuth flow was impossible regardless of scoping. Now written to
  the agent .env (from the request, else inherited from HSM's own env) and
  referenced from the mcp_servers env block.
- S5: googleIdentity was interpolated into YAML unvalidated. "a: b" broke the
  parse (MCP exits 1 — the exact regression this fixes) and a newline could
  inject permission entries past the deny-by-default. Now `^[A-Za-z0-9._-]+$`.

Found while fixing the above, not in the audit: generateEnvContent wrote a
wizard-supplied Brave key as BRAVE_API_KEY only. The runtime reads exclusively
BRAVE_SEARCH_API_KEY (plugins/web/brave_free/provider.py, and the auto-detect
table in tools/web_tools.py); nothing reads BRAVE_API_KEY. So the key was
invisible to the agent, brave-free reported itself unavailable, and the
search_backend pin added in this PR would have fallen straight through to
auto-detect — inert. Both names are now written.

Also: N1 the probe now requires node_modules as well as dist/index.js (the
bundle imports bare googleapis/js-yaml, so a pruned checkout passed and exited
1 at runtime); N2 non-absolute GOOGLE_MCP_DIR is rejected (Docker reads a
relative volume source as a named volume, mounting an empty dir over the
bundle); N4 the 400 now carries ok: false like its neighbours; S3 the generated
OAuth runbook now passes GOOGLE_TOKEN_DIR and the client creds, without which
it hit EROFS on the read_only container.

Tests: 18 (was 12). Verified by mutation — reverting each of the six fixes
individually fails a test. 911 pass on the real tree, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juniperbevensee
juniperbevensee force-pushed the dev/juniperbevensee/google-mcp-deploy-correctness branch from 3589b3f to 04cb2dc Compare July 30, 2026 00:22
@juniperbevensee juniperbevensee changed the title fix(deploy): make Google MCP actually work for newly created agents fix(deploy): correct the Google MCP wiring for newly created agents Jul 30, 2026
@juniperbevensee
juniperbevensee merged commit 5382ad2 into main Jul 30, 2026
3 checks passed
@juniperbevensee
juniperbevensee deleted the dev/juniperbevensee/google-mcp-deploy-correctness branch July 30, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant