Picture this. You call an MCP server that ships inside a plugin, and every call comes back with an authentication error. The token is set. The very same server connects fine when you add it directly in Claude Code. Only the Cowork path fails.
That is a stomach-tightening kind of bug, because the suspects line up so quickly: a wrong key, a dead server, a permission problem. I stopped on issue #94395 because it describes a fourth cause that is none of those.
Here is the short version first. There is a report that in Cowork, a ${VAR} in a plugin-bundled MCP config is passed to the server as the literal string, depending on the variable's name. Below I go through what the report says and how to check, in a few minutes, whether it applies to you.
What this article is, and is not
Everything here rests on a public issue report. As of November 2, 2026 the issue is still open and has no reply from Anthropic. The reporter was on Windows 11 with Cowork. I did not reproduce the behavior in Cowork myself.
What I did run is the scan script later in this article, against a sample config, and its output matched what I expected. Treat the cause as reported, not confirmed, and treat the fix as an open question.
The reported symptom in one sentence
The reporter had a plugin .mcp.json like this:
{
"mcpServers": {
"testmo": {
"command": "npx",
"args": ["-y", "@example/testmo-mcp-server@latest"],
"env": {
"TESTMO_API_TOKEN": "${TESTMO_API_TOKEN}",
"TESTMO_BASE_URL": "${TESTMO_BASE_URL}"
}
}
}
}The official docs have a section called "Environment variable expansion in .mcp.json" that describes the ${VAR} form. According to the report, though, Cowork does not expand these, and the server receives the text ${TESTMO_API_TOKEN} as is.
Setting the variable at the OS level, putting it in the env block of ~/.claude/settings.json, and restarting the app all made no difference. So this is not the "variable is unset, so it came out empty" case.
The clue is one line in the log
What the reporter found was a warning in the app log:
[warn] [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)It appears right after the plugin is loaded, before any server is started. The reporter says the same line appeared whether or not the variable was set, which suggests a check on the variable's name rather than its value. That is the reporter's reading, not something I have verified.
The names reported as expanding were the built-in ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_DATA} and ${CLAUDE_PLUGIN_ROOT}. Anything else, including documented-style examples such as ${API_KEY}, reportedly stayed literal.
What I find important is that nothing shows up in the UI. You only see the authentication failure; the cause lives in a warn line. I suspect that is why so few people trace the symptom back to it.
Three checks, in this order
Deciding the order up front takes a lot of the anxiety out. This is mine:
- Search the logs for
outside the MCP stdio safelist. - If it appears, count the
${…}references in that plugin's config that are not built-in names. - If there are any, consider moving the secret out of the config and having the server read it itself.
The first is a plain search across the log folder. On the reporter's Windows machine it was %APPDATA%\Claude\logs\main.log. I have not confirmed where the logs live on macOS or elsewhere, so find your own log folder and search for the same string.
# Replace LOG_DIR with your own Claude log folder
grep -rn "outside the MCP stdio safelist" "$LOG_DIR"If nothing matches, your problem is something else. Look at a mistyped key or a server-side failure first.
A scan to run before you distribute a plugin
The second step, counting non-built-in references, is something eyes will miss. So I wrote a small script that picks up references in .mcp.json and plugin.json and marks each as built-in or not.
Flagging everything would light up the built-in names every time, and you would stop reading the output. So built-in names print as OK and everything else as RISK.
#!/usr/bin/env python3
"""mcp_env_preflight.py — list ${VAR} references in a plugin's bundled MCP config
usage: python3 mcp_env_preflight.py ./my-plugin
"""
import json, re, sys
from pathlib import Path
# Names reported as expanding in issue #94395. Verify against your own logs
BUILTIN = {"CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_DATA", "CLAUDE_PLUGIN_ROOT"}
REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}")
def servers_of(path: Path):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(f"READ-FAIL {path}: {e}")
return {}
# .mcp.json comes both as {"mcpServers": {...}} and as a bare {...}
return data.get("mcpServers", data) if isinstance(data, dict) else {}
def refs_in(value):
if isinstance(value, str):
return set(REF.findall(value))
if isinstance(value, list):
return set().union(*(refs_in(v) for v in value)) if value else set()
if isinstance(value, dict):
return set().union(*(refs_in(v) for v in value.values())) if value else set()
return set()
def main(root: str) -> int:
base, risky = Path(root), 0
files = [base / ".mcp.json", base / ".claude-plugin" / "plugin.json"]
for f in files:
if not f.exists():
continue
for name, cfg in servers_of(f).items():
if not isinstance(cfg, dict):
continue
for field in ("command", "args", "env", "headers", "url"):
for var in sorted(refs_in(cfg.get(field))):
ok = var in BUILTIN
risky += 0 if ok else 1
print(f"{'OK ' if ok else 'RISK'} {f.name} server={name} {field}: ${{{var}}}")
print(f"--- non-built-in references: {risky}")
return 1 if risky else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))I ran it on the testmo config above, with a default-valued reference and ${CLAUDE_PLUGIN_DATA} added:
OK .mcp.json server=testmo env: ${CLAUDE_PLUGIN_DATA}
RISK .mcp.json server=testmo env: ${TESTMO_API_TOKEN}
RISK .mcp.json server=testmo env: ${TESTMO_BASE_URL}
--- non-built-in references: 2The exit code is 1 whenever a RISK line exists, so you can put it in CI or a pre-commit hook and stop a stray reference mechanically.
Two notes on why it is written this way. BUILTIN is a constant so that you edit one place when the list changes. The ${NAME:-default} form is matched too, because a default does not obviously exempt a non-built-in name, and I did not want to miss it.
Read it instead of passing it
The third step is my own design idea and is untested in Cowork. I want that to be unmistakable.
Read plainly, the report says the route of naming a value inside the config is closed. If so, one option is to keep the value out of the config and let the server read it at run time. For example, the user places a token file under the ${CLAUDE_PLUGIN_DATA} directory, which the report says does expand, and the server's start script reads it.
There is a cost. The secret now exists as a file, so permissions and sharing get stricter. If you share whole folders, you also need to count exposed keys, and as I wrote in counting keys before handing a folder to an AI, filenames alone will not find them.
The reporter also says they cannot tell whether the restriction is intentional. If it is, I would expect a published list of allowed names and an official way for users to supply values. Until then, I would rather have the plugin author write up front that "this MCP server may not receive its key in Cowork" than build a clever workaround and spend the recipient's time.
How far to trust the result
Even if the log shows the warning and your config has non-built-in references, you cannot conclude that this is the cause of your failure. The warning fires when the config is loaded; what actually reached the server can only be checked on the server side.
When you need certainty, log only the shape of the variable at server start, not its value. Printing the value would put your key in the log.
# Add to the server's start script (records only whether it was expanded, never the value)
case "${TESTMO_API_TOKEN:-}" in
'') echo "token: empty" >&2 ;;
'${'*) echo "token: left as literal placeholder" >&2 ;;
*) echo "token: set (length ${#TESTMO_API_TOKEN})" >&2 ;;
esacIt writes to standard error on purpose. Anything written to standard output would corrupt the MCP protocol stream.
Telling "empty", "left literal" and "set" apart lets you separate a wrong key from an expansion problem.
Look at the shape of what arrived, never at the secret itself. As an indie developer, I keep that line even on the nights when I am in a hurry.
Next step
Run the scan once against your plugin folder. If it reports zero RISK lines, this article's symptom is not yours to worry about. If it reports one or more, search the logs for outside the MCP stdio safelist and compare the names it lists with yours.
When issue #94395 moves, I will rewrite this together with the list of built-in names.