[BUG] Cowork silently blocks plugin-bundled MCP `env` var expansion for any non-built-in name, contradicting documented `${VAR}` custom-secret support

Status Open
Maintainer reply None cached
Activity 0 comments · opened Sep 14, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet (closest related: #9427, #11927, #40372, #39125, #40747, #94321 — see Additional Information, none describe this specific mechanism)
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

A plugin-bundled MCP server's .mcp.json (or the equivalent inline mcpServers block in plugin.json — the docs say these are equivalent) declares a custom secret using the documented ${VAR} placeholder syntax:

{
  "mcpServers": {
    "testmo": {
      "command": "npx",
      "args": ["-y", "--@synapsestudios:registry=https://registry.npmjs.org/", "@synapsestudios/testmo-mcp-server@latest"],
      "env": {
        "TESTMO_API_TOKEN": "${TESTMO_API_TOKEN}",
        "TESTMO_BASE_URL": "${TESTMO_BASE_URL}"
      }
    }
  }
}

This is exactly the pattern documented at https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json, which gives "Authorization": "Bearer ${API_KEY}" as its own canonical example and introduces the feature as supporting "sensitive values like API keys."

In Cowork (the agentic mode in the Claude Desktop app), this placeholder is never expanded — regardless of whether the variable is actually set, and regardless of how it's supplied (OS-level environment variable, ~/.claude/settings.json, or otherwise). The app's own log shows this is a deliberate, name-based gate, not a "variable not set" fallback:

[PluginMcpHostConfig] Plugin "..." server "testmo": config references environment variables
outside the MCP stdio safelist: TESTMO_API_TOKEN, TESTMO_BASE_URL — left unexpanded
(the desktop host does not expose arbitrary host env to plugin servers)

This is logged at plugin-config-load time (immediately after [LocalPluginsReader] Found N enabled local plugins), before any attempt to spawn the server or resolve values from the environment — it's a static check against the variable names in the config, not a runtime "is this in process.env?" check. We confirmed this distinction directly: setting TESTMO_API_TOKEN/TESTMO_BASE_URL via OS-level environment variables, via ~/.claude/settings.json, and across full app restarts made no difference — the identical log line was produced every time. This also isn't the documented "unset variable" behavior (code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json's "reports a missing-variable warning... uses the unexpanded ${VAR} text as-is") — that's a different code path (reported via claude mcp list), with different wording, and doesn't explain a warning that fires identically whether or not the variable is set.

The only names that appear to be exempt from this gate are the built-in plugin placeholders (${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_DATA}, ${CLAUDE_PLUGIN_ROOT}) — any other name, including a documented custom-secret pattern like ${API_KEY}, is silently left as literal text.

Net effect: it is currently impossible for any plugin author to ship a plugin-bundled MCP server that needs a custom credential and have it work in Cowork — no matter which supported mechanism (OS env, project/user settings.json) is used to supply the value. There is also no error surfaced to the plugin author or the end user anywhere in the UI; the only trace is a [warn]-level line in main.log that nobody would think to check unless already deep into debugging a silent auth failure.

What Should Happen?

At minimum one of:

  1. ${VAR} expansion for plugin-bundled MCP servers in Cowork should work for arbitrary variable names, matching what's documented at the link above (and matching how it already works in Code) — or
  2. If restricting plugin-bundled servers to a fixed safelist of names is intentional (plausible, as a defense against a plugin harvesting arbitrary host secrets), that restriction and the actual allowed name-list need to be documented, and a real, supported path for custom secrets needs to exist for plugin-marketplace-format plugins specifically (the existing user_config/sensitive mechanism is documented only for the separate Desktop Extension .mcpb format, and is itself reportedly broken in Cowork per #39125) — or
  3. At the very least, this should surface as a visible, actionable error to the plugin author/user (e.g. in claude mcp list, or an in-app notice), not only a [warn]-level internal log line.

Error Messages/Logs

2026-08-10 19:15:47 [warn] [PluginMcpHostConfig] Plugin "plugin_01WKRAPZXNyF6R8wVfqtnqpJ" server "testmo": config references environment variables outside the MCP stdio safelist: TESTMO_API_TOKEN, TESTMO_BASE_URL — left unexpanded (the desktop host does not expose arbitrary host env to plugin servers)

First observed 2026-08-10, persisted unchanged across an app update (1.26832.0 → 1.28929.0) and every subsequent app restart through at least 2026-08-26.

Steps to Reproduce

  1. Build a plugin with a bundled .mcp.json (or inline mcpServers in plugin.json) declaring a stdio MCP server whose env block uses a custom ${VAR} placeholder for a secret (not one of the built-in ${CLAUDE_PROJECT_DIR}/${CLAUDE_PLUGIN_DATA}/${CLAUDE_PLUGIN_ROOT} placeholders).
  2. Set that variable via any supported mechanism — OS-level environment variable, ~/.claude/settings.json env block, etc. — and fully restart the app.
  3. Install/enable the plugin and start a Cowork session.
  4. Call a tool from the bundled MCP server that needs the credential — it fails (the server receives the literal string ${VAR}, not the value).
  5. Check %APPDATA%\Claude\logs\main.log — the [PluginMcpHostConfig] warning above appears at app startup, regardless of step 2.

Claude Model

None (occurs before any model interaction — this is plugin/MCP host configuration loading)

Is this a regression?

Unknown — first observed 2026-08-10 (earliest available log retention), so we can't confirm whether it ever worked differently before that.

Last Working Version

_No response_

Claude Code Version

App versions 1.26832.0 through 1.28929.0 (bug persisted across this update)

Platform

Claude Desktop / Cowork

Operating System

Windows 11 Home (10.0.26200)

Terminal/Shell

N/A (Cowork, not a terminal session)

Additional Information

Related issues checked and ruled out as not covering this specific mechanism:

  • #9427 — "env variable expansion not working in plugin .mcp.json" (Claude Code CLI, inconsistent/version-dependent, no safelist identified, no Cowork angle)
  • #11927 — "'env' vars from .claude/settings.json not passed to the plugins/mcps" (Claude Code CLI, project-scope settings.json propagation regression, affects arbitrary variable names — not a name-based safelist)
  • #40372 — same general symptom class as #9427, Code-side, no maintainer explanation
  • #39125 — "Cowork sessions don't inject user_config env vars into MCP server processes" — a related gap in the other supported secrets mechanism (Desktop Extension .mcpb manifests' user_config/sensitive), also broken in Cowork, but a different plugin format and a different failure mode (values entirely absent vs. present-but-unexpanded-with-explicit-safelist-warning)
  • #40747 — "Org-managed plugins have no way to inject secrets into MCP servers" (Cowork cloud sandbox) — same underlying pain point, but the reporter didn't identify a name-based safelist as the cause
  • #94321 — "Plugin MCP OAuth is per-session and not durable; Cowork sessions can never authenticate one at all" — same category (plugin MCP auth broken in Cowork) but a different specific mechanism (OAuth session durability, not env var expansion)
  • #90101 — a classic (non-Cowork) Desktop claude_desktop_config.json bug, closed invalid/"doesn't seem related to Claude Code" — confirms plain classic-Desktop bugs get rejected here, but this report is specifically about Cowork (labeled area:cowork elsewhere in this tracker), not classic Desktop, so should be in scope

Happy to provide the full main.log excerpt or test further against a minimal repro plugin if useful.

View original on GitHub ↗