◉CLAUDE LABJP
●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 28 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 28 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…
Articles/Cowork
◈ Cowork/2026-11-02Intermediate

Only the Plugin's MCP Server Fails to Authenticate — Triage for an Unexpanded ${VAR}

If only a plugin-bundled MCP server fails to authenticate in Cowork, its ${VAR} placeholders may be reaching the server as literal text. A log check and a pre-distribution scan, based on the report in issue #94395.

Cowork62plugins5MCP60environment variables6troubleshooting102

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:

  1. Search the logs for outside the MCP stdio safelist.
  2. If it appears, count the ${…} references in that plugin's config that are not built-in names.
  3. 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: 2

The 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 ;;
esac

It 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ Cowork2026-10-26
When Cowork Says a Dropbox File Doesn't Exist: The Order I Check Things In
The file is right there in Finder, yet Cowork says it can't find it. The cause may be a Dropbox online-only file. Here is how to tell, how to confirm it with two commands, how to fix it, and where I draw the line on what to share.
◈ Cowork2026-10-20
I Sorted 32 Connector Tool Names Into Read, Write and Delete — Four Wouldn't Sort by Name
I wrote a small script that sorts the tool names of my Cowork connectors into read, write and delete by verb. The first pass left nine undecided, the fix left four, and here is how I now set approvals.
◈ Cowork2026-05-06
Claude in Chrome Not Working? A Practical Troubleshooting Guide
Buttons that do nothing, pages that never load, tasks that report success but change nothing. Symptom-by-symptom fixes, plus the 42-run log where adding waits and verification steps moved completion from 62% to 95%.