Authenticating remote MCP servers: headers, env expansion, and the 2026 spec

Every headless client that matters for agent work — Claude Code, the Agent SDK, Cursor, OpenClaw — accepts a plain bearer token in an HTTP header for a remote MCP server. That's not a stopgap; it's the pragmatic default, and the MCP spec's mid-2026 revision made OAuth stricter rather than making header auth obsolete.

On this page

.mcp.json and claude mcp add

A remote MCP server goes in .mcp.json (or --mcp-config inline) as a type: "http" entry with a headers map, same as any other outbound request:

{
  "mcpServers": {
    "ourservice": {
      "type": "http",
      "url": "https://api.ourservice.dev/mcp",
      "headers": { "Authorization": "Bearer ${OURSERVICE_API_KEY}" }
    }
  }
}

Or via the CLI, which writes the same shape for you:

claude mcp add --transport http ourservice https://api.ourservice.dev/mcp \
  --header "Authorization: Bearer $OURSERVICE_API_KEY" --scope project

"streamable-http" is accepted as an alias for "http" in JSON config files, if you're copying an example that uses the more explicit transport name.

Env expansion

${VAR} and ${VAR:-default} both expand inside header values, so the literal key never has to live in a committed config file:

{ "headers": { "Authorization": "Bearer ${OURSERVICE_API_KEY:-}" } }

An empty default (${VAR:-}) is worth using deliberately over leaving the fallback off entirely: it means a missing environment variable produces an empty, obviously-broken header instead of a config file that fails to parse — easier to notice and to grep logs for than a startup crash with no context.

Config scopes and precedence

claude mcp add's --scope flag controls where the entry is written, and scopes have a fixed precedence when the same server name is defined in more than one:

local   (highest — private, ~/.claude.json, not committed)
project (.mcp.json, git-shared — the usual choice for a team-wide server)
user    (lowest — cross-project, your own machine only)

project is almost always right for a service every teammate needs configured the same way — commit the server URL and header name, never a literal key, and let each person's own shell environment supply the value that ${VAR} expands.

The same shape in the Agent SDK

The Claude Agent SDK (TypeScript and Python) takes an identical headers map on its own mcp_servers/mcpServers option — if you already have a working .mcp.json entry, porting it into SDK options is close to copy-paste:

options = ClaudeAgentOptions(
    mcp_servers={"ourservice": {"type": "http", "url": "https://api.ourservice.dev/mcp",
                                 "headers": {"Authorization": f"Bearer {os.environ['OURSERVICE_API_KEY']}"}}},
    allowed_tools=["mcp__ourservice__*"],
)

Neither SDK automates OAuth — there's no browser flow built in for an MCP server that requires it. You complete whatever OAuth exchange the server needs yourself, the same way you would outside the SDK, and drop the resulting access token into headers exactly like a static API key. See the Agent SDK guide for the rest of that SDK's MCP wiring, including canUseTool alongside it.

Cursor and OpenClaw

The same header-map shape carries over almost verbatim to the other clients worth planning for:

The one real holdout is claude.ai's own web "Add custom connector" UI, which as of this writing is OAuth-only — there's no header-auth path through that specific surface. Every CLI/SDK-side integration above supports headers just fine; it's only the hosted web connector flow that doesn't.

What the 2026-07-28 spec changed

MCP's 2026-07-28 revision is a real, dated spec change, not a rumor — and it's worth knowing what it did and didn't touch, since "the spec changed" gets repeated vaguely enough that it's easy to assume it broke header auth. It didn't:

None of that touches plain header-based API-key auth, which was never part of the session or OAuth machinery to begin with — it's a property of the transport layer (an HTTP header on every request), and the spec revision left that path alone. If anything, the OAuth tightening is a point in favor of sticking with header auth for a v1 server: standing up a spec-compliant authorization server (RFC 9207-correct iss checks, CIMD support) is meaningfully more work than issuing API keys, for a client population that already supports headers universally.

Checking connection status

Whichever client you're driving from the Agent SDK, per-server connection status — pending, connected, failed, needs-auth, or disabled — arrives in the system:init message at session start and is pollable afterward via mcpServerStatus() (TypeScript) or get_mcp_status() (Python). Check it before assuming a tool call against that server will actually succeed, especially right after a deploy that rotated the key on one side but not the other — a stale header produces failed, not a slow retry.