Skip to content

feat(sdk): resolve user_config from reverse mcp_config.env lookup#82

Merged
mgoldsborough merged 1 commit intomainfrom
feat/manifest-env-alias
Apr 20, 2026
Merged

feat(sdk): resolve user_config from reverse mcp_config.env lookup#82
mgoldsborough merged 1 commit intomainfrom
feat/manifest-env-alias

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

Summary

Adds a new resolution tier to the SDK's `gatherUserConfig`: the bundle's existing `server.mcp_config.env` declaration is read in reverse at resolve time. If a bundle maps a field via `"NEWSAPI_API_KEY": "${user_config.api_key}"`, then a host `process.env.NEWSAPI_API_KEY` satisfies that field.

No new schema field. No new convention. No `_meta` extension. The bundle's canonical MCPB declaration is the single source of truth — we just use it both ways.

Why

Host runtimes that wrap mpak have been inventing their own env-var naming schemes to let users satisfy `user_config` fields from the host's process env. NimbleBrain shipped `NB_CONFIG_{SCOPE}{NAME}{FIELD}` last week — ugly, forces users to rename exports away from industry defaults (`ANTHROPIC_API_KEY` becomes `NB_CONFIG_NIMBLEBRAIN_WEBFETCH_ANTHROPIC_API_KEY`), and carries a special-case `inc`-stripping rule for one specific GitHub org.

Pushing this into the SDK, using the mapping the bundle already declares, removes the need for any host-specific convention. Every mpak host benefits uniformly. Bundles wrapping de-facto-standard services (Anthropic, OpenAI, GitHub) work with their canonical env vars by default.

Resolution order

Per user_config field, first non-empty wins:

  1. `options.userConfig[field]` — caller override (unchanged)
  2. stored `config.json` value — from `MpakConfigManager` (unchanged)
  3. env alias via `mcp_config.env` — new
  4. manifest `default` — unchanged
  5. missing required → `MpakConfigError` — unchanged

Semantics

  • Only whole-value single-substitution templates participate. `"X": "${user_config.a}"` is reversible. `"X": "prefix-${user_config.a}"` or `"X": "${user_config.a}-${user_config.b}"` are not — they're skipped and the field falls through.
  • Empty strings are treated as absent to prevent an accidentally-cleared export from masking a stored value or default.
  • Multiple env vars mapped to the same field iterate in declaration order — first non-empty wins. This lets bundles declare canonical names before aliases (`ANTHROPIC_API_KEY` then `CLAUDE_API_KEY`).
  • Bundles with no `mcp_config.env` declarations have an empty env tier — fallthrough is unchanged.

Test plan

  • 7 new tests in `tests/mpak.test.ts` covering: single mapping, full precedence ordering across all five tiers, empty-env-string treated as absent, multi-alias declaration order (canonical wins when both set), literal values ignored (no substitution to reverse), composite templates skipped, and missing-mapping fallthrough
  • All 200 existing SDK tests pass unchanged
  • `pnpm --filter @nimblebrain/mpak-sdk typecheck` clean
  • `pnpm --filter @nimblebrain/mpak-sdk lint` clean
  • `prettier --check` clean on new code

Follow-ups

After this publishes as `0.4.0`, NimbleBrain will drop its `NB_CONFIG_*` convention (`envVarName`, `normalizeScope`, and the process-env tier in `resolveUserConfig`) and rely on this SDK behavior instead. Net ~40 lines deleted from NimbleBrain.

A bundle that maps a user_config field to a spawn env var — e.g.
`"NEWSAPI_API_KEY": "\${user_config.api_key}"` in mcp_config.env —
implicitly declares "the api_key field is about the NEWSAPI_API_KEY
env var." The SDK already uses this declaration in one direction
(substituting resolved values into the spawn env). Now it reads it
in reverse at resolve time: if the host process has NEWSAPI_API_KEY
set non-empty, its value satisfies the field.

New resolution order in gatherUserConfig (first non-empty wins):

  1. options.userConfig[field]         — caller override (unchanged)
  2. stored config.json                — MpakConfigManager (unchanged)
  3. env alias via mcp_config.env      — NEW
  4. manifest default                  — unchanged
  5. missing required → MpakConfigError

Only whole-value single-substitution templates participate in the
reverse mapping. Templates with literal text or multiple substitutions
have no clean inverse and are skipped — the field falls through to
default/error as usual. Declaration order determines alias priority
when multiple env vars map to one field.

No new schema field. No new convention. The bundle's existing
canonical mcp_config.env declaration is the single source of truth.
Host runtimes that previously needed their own env-var naming scheme
(e.g. NimbleBrain's NB_CONFIG_* prefix) can drop it — the SDK handles
the mapping uniformly across every mpak host.

Bump to 0.4.0 (minor — new resolution tier, backward compatible).

Tests: 7 new tests in mpak.test.ts covering single mapping, full
precedence ordering, empty-env-string treated as absent, multi-alias
declaration order, literal values ignored, composite templates
skipped, and missing-mapping fallthrough. All 207 SDK tests pass.
@mgoldsborough mgoldsborough merged commit 1e7e0d1 into main Apr 20, 2026
3 checks passed
mgoldsborough added a commit that referenced this pull request Apr 20, 2026
Three items from the review on #82:

  1. Regex asymmetry (real bug). The forward substitution in
     resolveCommand uses [^}]+ for the user_config field name, but the
     reverse lookup in buildEnvAliasMap used the stricter JS-identifier
     pattern [A-Za-z_][A-Za-z0-9_]*. A bundle with a hyphenated field
     name (valid JS object key, commonly used) would forward-substitute
     at spawn time but silently skip the reverse tier at resolve —
     asymmetric behavior that's hard to debug. Aligned both to [^}]+
     so every field that can appear in a template also participates in
     reverse lookup. Regression test pins the hyphenated-name path.

  2. Multi-alias spawn semantics comment. When a bundle maps the same
     field to multiple env vars (e.g. ANTHROPIC_API_KEY and
     CLAUDE_API_KEY), all of them get populated at spawn even if only
     one was set in the host process. This is intentional — the
     bundle's declaration is "these names mean this field to me" —
     but non-obvious enough to document at substituteEnvVars. Added
     JSDoc block explaining the contract.

  3. Non-string mcp_config.env value test. The guard in
     buildEnvAliasMap (`typeof template !== 'string' continue`)
     existed without test coverage. Writing the test surfaced a
     latent bug: substituteEnvVars had no matching guard, so a
     hand-built manifest with a non-string value would crash with
     `value.replace is not a function`. Added the same guard to
     substituteEnvVars and kept it in lockstep via comment. Zod
     catches this at parse in production; the guard is defense for
     tests and any future path that bypasses parse.

Bump 0.4.0 → 0.4.1 (patch — bugfix + test coverage + docs).

Tests: 209 pass (was 207, +2 new).
mgoldsborough added a commit that referenced this pull request Apr 20, 2026
Three items from the review on #82:

  1. Regex asymmetry (real bug). The forward substitution in
     resolveCommand uses [^}]+ for the user_config field name, but the
     reverse lookup in buildEnvAliasMap used the stricter JS-identifier
     pattern [A-Za-z_][A-Za-z0-9_]*. A bundle with a hyphenated field
     name (valid JS object key, commonly used) would forward-substitute
     at spawn time but silently skip the reverse tier at resolve —
     asymmetric behavior that's hard to debug. Aligned both to [^}]+
     so every field that can appear in a template also participates in
     reverse lookup. Regression test pins the hyphenated-name path.

  2. Multi-alias spawn semantics comment. When a bundle maps the same
     field to multiple env vars (e.g. ANTHROPIC_API_KEY and
     CLAUDE_API_KEY), all of them get populated at spawn even if only
     one was set in the host process. This is intentional — the
     bundle's declaration is "these names mean this field to me" —
     but non-obvious enough to document at substituteEnvVars. Added
     JSDoc block explaining the contract.

  3. Non-string mcp_config.env value test. The guard in
     buildEnvAliasMap (`typeof template !== 'string' continue`)
     existed without test coverage. Writing the test surfaced a
     latent bug: substituteEnvVars had no matching guard, so a
     hand-built manifest with a non-string value would crash with
     `value.replace is not a function`. Added the same guard to
     substituteEnvVars and kept it in lockstep via comment. Zod
     catches this at parse in production; the guard is defense for
     tests and any future path that bypasses parse.

Bump 0.4.0 → 0.4.1 (patch — bugfix + test coverage + docs).

Tests: 209 pass (was 207, +2 new).

Co-authored-by: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com>
mgoldsborough added a commit that referenced this pull request Apr 20, 2026
…ar guidance

Updates bundles/user-config.mdx to reflect the env-alias reverse-lookup
resolver shipped in SDK 0.4.0 (#82) and the envAliases field on
MpakConfigError added in 0.5.0 (#85).

- 'How Values Are Resolved' tiers now include caller override and
  env-alias lookup with SDK version annotations.
- New 'Use standard env var names' best-practice section with a
  reference table explaining why canonical names give users
  zero-config startup.
- Existing env-var Aside cross-links the new guidance.
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