Skip to content

fix(connection): resolve ${secret:...} refs in stdio env before spawn (closes #65)#66

Merged
rodaddy merged 2 commits into
mainfrom
fix/stdio-env-secret-resolution
Jul 5, 2026
Merged

fix(connection): resolve ${secret:...} refs in stdio env before spawn (closes #65)#66
rodaddy merged 2 commits into
mainfrom
fix/stdio-env-secret-resolution

Conversation

@rodaddy

@rodaddy rodaddy commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Root cause

For stdio services, ${secret:...} references in the service's env (and args) reached the spawned child unresolved on any connect path that did not resolve them upstream.

  • src/connection/transport.ts:82 spreads service.env raw into the child mergedEnv.
  • src/connection/transport.ts:83 runs only ${VAR} expansion, and only on args -- never the secret resolver.

So a literal ${secret:...} env value (or a ${secret:...} arg that expandEnvVars blanks to empty, since no such shell var exists) landed in the child. Observed on the n8n service: n8n-mcp's getN8nApiConfig() validates N8N_API_URL with zod .url(), the literal ref fails validation, and all 15 n8n management tools report the misleading "n8n API not configured". (This is the true root cause behind rtech-mcps#19; source: remote->local was a red herring -- source is daemon routing, not env-source.)

Why some paths worked and some didn't

resolveServiceSecretRefs was already called on the direct CLI path (resolveDirectServiceConfig) and in the daemon pool (src/daemon/pool.ts:140). But the pool's HTTP/WebSocket stdio fallback constructors -- connectFallback (pool.ts:435) and connectWsFallback (pool.ts:359) -- build a stdio config from raw fb.env / fb.args and call connectToService with no resolution. That is a proven leak in the current tree, and it means resolution was caller-dependent rather than guaranteed.

The fix

Resolve at the single universal stdio chokepoint -- connectToService in src/connection/client.ts -- which every stdio spawn passes through (direct path, daemon pool, and both fallbacks). One surgical change closes every path instead of patching each caller.

  • Uses the same resolveServiceSecretRefs(name, config, resolver) + VaultwardenSecretResolver pattern the working paths use.
  • Idempotent: resolveServiceSecretRefs short-circuits via hasSecretRefs when no ${secret: remains, so callers that already resolve upstream incur zero extra Vaultwarden lookups -- no double resolution.
  • Resolver is injectable (options.resolver) for testing; default stays VaultwardenSecretResolver.
  • Log labels intentionally keep the unresolved command/args so resolved secret values never reach logs.

Test Plan

New fixture tests/fixtures/env-echo-mcp-server.ts (an MCP server whose echo_env tool returns its own process.env + argv) plus tests/connection/client.test.ts cases:

  1. A ${secret:...} env value reaches the child resolved (mocked resolver) -- child never sees ${secret:.
  2. A ${secret:...} arg is resolved before spawn.
  3. A ref-free config triggers zero resolver calls (idempotency guard).
bun test tests/connection/client.test.ts
# 10 pass / 0 fail

Mutation-verified: reverting the fix (resolved.* -> service.*) makes the child read back the literal ${secret:RTech MCPs - foo#fields.N8N_API_URL} env and a stripped --token= arg -- both new tests fail. This reproduces #65 exactly.

Full suite: bun test -> 1080 pass / 0 fail across three repeated runs. bun run typecheck clean.

Verifying n8n lights up after

With this merged, on a host where the n8n stdio service uses ${secret:...} in its env, the child receives the real N8N_API_URL/N8N_API_KEY, zod .url() validation passes, and the 15 n8n management tools stop reporting "n8n API not configured":

mcp2cli n8n <management-tool>   # previously "n8n API not configured", now works

Closes #65.

🤖 Generated with Claude Code

rodaddy and others added 2 commits July 5, 2026 01:22
…closes #65)

For stdio services, ${secret:...} references in `env` (and `args`) reached the
spawned child UNRESOLVED on any connect path that did not resolve upstream.
transport.ts spreads `service.env` raw into the child env and only runs
${VAR} expansion on args, never the secret resolver -- so a literal
`${secret:...}` (or a blanked `${secret:...}` arg) landed in the child.
Observed on `n8n`: n8n-mcp's zod `.url()` validation of N8N_API_URL rejected
the literal ref and all 15 management tools reported the misleading
"n8n API not configured".

The direct CLI path (resolveDirectServiceConfig) and the daemon pool
(pool.ts) already resolve, but the pool's HTTP/WebSocket stdio *fallback*
constructors (connectFallback / connectWsFallback) build a stdio config from
raw fb.env / fb.args and call connectToService with no resolution -- a proven
leak. Rather than patch each caller, resolve at the single universal stdio
chokepoint: connectToService, which every stdio spawn (direct, pool, and both
fallbacks) passes through.

Resolution is idempotent -- resolveServiceSecretRefs short-circuits via
hasSecretRefs when no ${secret:} remains -- so callers that already resolve
upstream incur no extra Vaultwarden lookups. The resolver is injectable
(options.resolver) for testing; default stays VaultwardenSecretResolver.

Log labels intentionally keep the UNRESOLVED command/args so resolved secret
values never reach logs.

Test: new env-echo fixture MCP server + client.test.ts cases assert a
${secret:...} env value and a ${secret:...} arg both reach the child RESOLVED
(mocked resolver), and that a ref-free config triggers zero resolver calls.
Mutation-verified: reverting the fix makes the child read back the literal
`${secret:...}` env and a stripped `--token=` arg, failing both.

Verified: bun run typecheck clean; full suite 1080 pass / 0 fail across three
repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ct TS)

Top-level `for await` requires the file be a module. Adds `export {}` so
strict-context typecheck accepts the fixture; repo tsc was already lax.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rodaddy rodaddy merged commit f63be3e into main Jul 5, 2026
4 checks passed
@rodaddy rodaddy deleted the fix/stdio-env-secret-resolution branch July 5, 2026 05:38
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.

stdio env: ${secret:...} refs not resolved before spawn (breaks any stdio service using secret-refs in env)

1 participant