fix(deploy): correct the Google MCP wiring for newly created agents - #189
Merged
juniperbevensee merged 2 commits intoJul 30, 2026
Merged
Conversation
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
force-pushed
the
dev/juniperbevensee/google-mcp-deploy-correctness
branch
from
July 30, 2026 00:22
3589b3f to
04cb2dc
Compare
juniperbevensee
deleted the
dev/juniperbevensee/google-mcp-deploy-correctness
branch
July 30, 2026 01:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
GOOGLE_MCP_DIRprobed only~/Documents/GitHub/google-multiplayer-mcp; the repo is commonly cloned asgoogle-mcpmcp_serversentry, no error--config /opt/google/config.yaml/opt/google/tokensis mounted →readFileSync→ exit 1GOOGLE_TOKEN_DIRset nowhere$HOME/.nimbleco-google/tokens; withHOME=/opt/datathe/opt/google/tokensmount was dead weightPlus:
GOOGLE_CLIENT_ID/SECRETwere never provisioned and HSM never writes~/.nimbleco-google/config.json, soloadGlobalConfig()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 themcp_serversenv block.Security: why the default grants nothing
google-multiplayer-mcp'spermissions.ts:/** Get allowed folder IDs for a service. Empty array = no restriction. */Empty
foldersmeans unrestricted. Sodrive: {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 ataccess: none, with a header explaining how to scope it. A test asserts every generated service isnone, not a sampled few.Found while fixing the above
generateEnvContentwrote a wizard-supplied Brave key asBRAVE_API_KEYonly. The runtime reads exclusivelyBRAVE_SEARCH_API_KEY(plugins/web/brave_free/provider.py, plus the auto-detect table intools/web_tools.py); nothing readsBRAVE_API_KEY. The key was invisible to the agent — so thesearch_backend: brave-freepin 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
mkdirSyncand the.envwrite and returned from inside thetry, making thecatch'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 byrm -rffrom 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
googleIdentityinterpolated 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 requiresnode_modulesas well asdist/index.js(the bundle imports baregoogleapis/js-yaml, so a pruned checkout passed and exited 1 at runtime); non-absoluteGOOGLE_MCP_DIRis rejected (Docker reads a relative volume source as a named volume, mounting an empty dir over the bundle); the 400 carriesok: false; and the generated OAuth runbook now passesGOOGLE_TOKEN_DIRand 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 passedon the real tree,tsc --noEmitclean.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
duplicateOverlayrecursively copies the agent data dir, so a duplicate inherits the source'sgoogle-permissions.yamland itsgoogle-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