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:
- Cursor —
~/.cursor/mcp.jsonaccepts aheadersobject per server, with one caveat: Cursor silently switches to OAuth instead if the server advertises RFC 9728 protected-resource discovery, even if you configured a header. If your server supports both, decide deliberately which one you want a Cursor client to use, rather than assuming the header config you wrote is the one that ends up in effect. - OpenClaw — its MCP client config takes
transport: "streamable-http"plus aheadersmap, the same idea under a different transport name. Secrets there go throughopenclaw.json's own indirection ({"source": "env", "provider": "default", "id": "OURSERVICE_API_KEY"}) rather than a literal string in the header value, which matters if the skill runs inside a sandbox — sandboxed runs needsandbox.docker.envinstead of the host-process env lookup.
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:
- Sessions were removed entirely. No more
Mcp-Session-Idor aninitializehandshake — every request is self-describing via_meta.io.modelcontextprotocol/protocolVersion, with newMCP-Protocol-Version/Mcp-Method/Mcp-Nameheaders now required. Streamable HTTP is POST-only to a single endpoint, returning either a JSON body or a per-request SSE stream; GET/SSE long-lived streaming and resumability were dropped in favor of an opt-insubscriptions/listen. - OAuth got stricter, not looser.
issvalidation (RFC 9207) and a mandatoryapplication_typein Dynamic Client Registration are both new requirements. Dynamic Client Registration itself is now deprecated in favor of Client ID Metadata Documents (CIMD), though DCR still works for back-compat today.
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.