anthropics / anthropics/claude-code

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

Open
#94,395 0 comments 0 reactions 0 assignees View on GitHub
area:cowork area:mcp area:plugins bug has repro platform:windows
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### 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:

```json
{
"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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Reproduce the plugin-bundled .mcp.json or plugin.json configuration in a Cowork session, then inspect %APPDATA%\Claude\logs\main.log and compare the behavior with `claude mcp list`. Done means the documented custom `${VAR}` behavior works, or the allowed names, supported secret path, and user-facing failure are clearly established.

Written by the indexing model from the issue text.

Assessment

Tech stack
json
Domain
devtools, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.