[BUG] Plugin HTTP MCP ${VAR} header expansion resolves to empty in 2.1.260 (works in 2.1.247 / 2.1.223) — bearer token never sent

Status Closed — not planned
Reported on v2.1.260
Maintainer reply None cached
Activity 2 comments · opened Sep 11, 2026 · closed Oct 12, 2026

✨ The following was generated via Claude, mistakes do happen.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

A plugin-defined HTTP MCP server whose Authorization header uses ${VAR} substitution fails to connect on 2.1.260 with HTTP 401, because the header is sent with no token in it. The identical plugin, environment variable, shell and config connect successfully on 2.1.247 and 2.1.223.

This bites desktop-app users hardest, because the Windows desktop app bundles its own CLI under %APPDATA%\Claude\claude-code\<version>\ — currently 2.1.260 — while a separate npm-global install on PATH may be on a working version. The result looks like a desktop-vs-terminal inconsistency, but it is not: 2.1.260 reproduces the failure when launched directly from a plain shell, with the desktop app out of the picture entirely.

Plugin manifest under test (redacted plugin name; any plugin with the same shape will do):

"mcpServers": {
  "sonarqube": {
    "type": "http",
    "url": "https://api.sonarcloud.io/mcp",
    "headers": {
      "Authorization": "Bearer ${SONAR_TOKEN}",
      "SONARQUBE_ORG": "example-org"
    }
  }
}

Version bisect — one machine, one shell, one config, one env var:

| CLI build | Origin | claude mcp list result |
| --- | --- | --- |
| 2.1.223 | npm-global on PATH | ✔ Connected |
| 2.1.247 | older desktop-bundled build, still on disk | ✔ Connected |
| 2.1.260 | current desktop-bundled build | ✘ Failed to connect — HTTP 401 |

All three were invoked from the same Bash session, in which SONAR_TOKEN is confirmed present. That isolates the variable to the CLI binary.

Evidence the token is valid and the header arrives empty. Three direct POSTs to the same endpoint from that same shell:

| Authorization value | HTTP |
| --- | --- |
| Bearer $SONAR_TOKEN (expanded by the shell) | 200 |
| Bearer ${SONAR_TOKEN} (literal, unexpanded) | 403 |
| Bearer (empty) | 401 |

The 401 returned by 2.1.260 matches the empty case, not the unexpanded-literal case — so this is not the trailing-brace corruption described in #84367. Note the limitation: the server returns the same 401 whether the header is empty or absent, and claude mcp get redacts the value on both builds, so I cannot distinguish "expanded to empty string" from "header dropped" from the client side. Either way the token does not leave the machine.

What Should Happen?

${SONAR_TOKEN} in a plugin manifest's mcpServers.*.headers should resolve from the CLI process environment on 2.1.260 exactly as it does on 2.1.247 and 2.1.223, and the server should connect.

Error Messages/Logs

plugin:example-plugin:sonarqube: https://api.sonarcloud.io/mcp (HTTP) - ✘ Failed to connect —
Server rejected the configured Authorization header (HTTP 401). Check that the token is valid
for this MCP endpoint — OAuth fallback is disabled when headers.Authorization is set.
Error detail: Streamable HTTP error: Error POSTing to endpoint:
{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"SonarQube token required.
Provide via Authorization: Bearer <token> header."}}

Steps to Reproduce

  1. Install a plugin whose manifest declares a type: "http" MCP server with "Authorization": "Bearer ${SOME_TOKEN}" in headers.
  2. Set SOME_TOKEN as a user-scope environment variable to a value the endpoint accepts, and open a fresh shell so it is inherited.
  3. Confirm the credential itself works from that shell, independently of Claude Code:

curl -s -o /dev/null -w "%{http_code}" -X POST <mcp-url> -H "Authorization: Bearer $SOME_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"diag","version":"1"}}}'
→ 200

  1. From that same shell, run claude mcp list using a 2.1.247 or 2.1.223 binary → server reports Connected.
  2. From that same shell, run claude mcp list using the 2.1.260 binary (on Windows: %APPDATA%\Claude\claude-code\2.1.260\claude.exe) → server reports Failed to connect (HTTP 401).

No config, plugin version, token or environment change between steps 4 and 5 — only the binary.

Claude Model

Opus (claude-opus-5) — not model-dependent; claude mcp list reproduces it with no model involved.

Is this a regression?

Yes

Last Working Version

2.1.247

Claude Code Version

2.1.260

Platform

Claude Max subscription (desktop app + npm-global CLI)

Operating System

Windows 11 Pro 10.0.26200

Terminal/Shell

Claude desktop app (bundled CLI) and Git Bash / cmd

Additional Information

  • Regression is bracketed to 2.1.248 – 2.1.260; 2.1.247 is the last build confirmed working here.
  • The plugin manifest is byte-identical across every cached version of that plugin tested, and there is no competing mcpServers entry in ~/.claude.json, so config drift is ruled out.
  • No profile script (.bashrc, .bash_profile, .profile, PowerShell $PROFILE) defines the variable — it is inherited from the process environment in every case.
  • Possibly related but distinct: #84367 (trailing brace corrupts the header → HTTP 400, on 2.1.222 which works here), #84314 (non-deterministic ${VAR} expansion for MCP env on Linux), #90074 (Windows desktop sanitizes the stdio MCP environment — ruled out here, since 2.1.260 fails with the variable present in its own environment), #91307 (Gitea MCP auth differs Desktop vs Terminal on macOS — that one is framed as host-app state, whereas this reproduces from a plain shell).
  • Practical impact: every plugin-distributed HTTP MCP server that authenticates via ${VAR} headers is silently unusable for desktop-app users on 2.1.260, and the error text ("Check that the token is valid") points investigation at the credential rather than at the client.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗