Headers helper

How a plugin's MCP entry gets a credential without one ever being written to disk — what we put in the config, what runs, and what comes back. This is also what makes one entry per connected account affordable, because otherwise each entry needs its own browser trip.

The value we write

One field on the shared config, renamed by each dialect writer. The command is the same for both agents — the credential does not differ by agent, only the key name does.

~/.claude.json

"mcpServers": {
  "linear-lu-1": {
    "type": "http",
    "url": "https://api.superset.sh/mcp/plugins/superset/linear?connection=conn-work",
    "headersHelper": "/Users/you/.superset/bin/superset auth mcp-headers"
  }
}

~/.codex/config.toml

[mcp_servers.linear-lu-1]
url = "https://api.superset.sh/mcp/plugins/superset/linear?connection=conn-work"
http_headers_helper = "/Users/you/.superset/bin/superset auth mcp-headers"

The path is the managed shim at ~/.superset/bin/superset, not the bare word superset. The agent runs this command itself, and its environment is not the Paradev terminal's — a bare name would resolve only when the agent happened to inherit our PATH.

It sits under auth rather than a group of its own, because what it prints is the CLI's own credential and nothing about MCP. It is hidden from --help: the audience is an agent reading a config we wrote, not a person at a prompt.

What runs, and what it returns

agent opens the server → runs the helper command → reads JSON from stdout → merges into request headers → calls the proxy

superset auth mcp-headers

{"Authorization":"Bearer sk_live_…"}

Stdout is the whole contract: a JSON object of string keys to string values, nothing else. The credential comes from the auth the CLI already holds, which is why there is nothing new to provision and nothing new to rotate.

Where the account list comes from

One entry per account needs the account list, and every writer of agent config runs where that list is not reachable: the desktop's is the main process, and the host-service's reconcile runs at session start in a terminal. So the renderer pushes what it can see into a cache beside the install ledger, and both writers read it from there.

~/.superset/plugins/connections.json

{
  "connections": [
    { "connector": "linear", "connectionId": "conn-work", "externalUserId": "lu-1" },
    { "connector": "linear", "connectionId": "conn-personal", "externalUserId": "lu-2" }
  ]
}

A cache, never a source of truth. An absent or unparseable file reads as no connections, which is the one-entry-per-connector shape — wrong only until the next sync, and never a failed write. The renderer watches the whole set rather than hooking the connect and disconnect mutations, so a connection made on the web or from another machine lands here too.

Both writers fill it from connectors.status and nowhere else, because the entry names are built from externalUserId and only that procedure carries it. A writer that fell back to connection ids would rename every entry the other had just named, and each rename orphans whatever the agent stored against the old name.

How the two agents differ

Claude Code Codex
Config key headersHelper http_headers_helper
When it runs Fresh on every connection Cached, and re-run when the server rejects — token refresh is built in
Limits Not documented 10s timeout, 64KB of output
Alternatives it also supports ${VAR} expansion inside headers env_http_headers, bearer_token_env_var

Codex's is the stronger implementation: it keys a cache on a refresh epoch and calls the helper again on an insufficient_scope challenge, so an expired credential repairs itself mid-session. Claude re-runs per connection, so a session started before a rotation keeps the old value until it reconnects.

Why a helper rather than a header

The config could carry "Authorization": "Bearer …" directly, and both agents would accept it. Three reasons not to:

What it unlocks

Each config entry authorizes separately: Claude stores credentials keyed <name>|<hash of the config>, so N entries means N browser round-trips, and any change to a URL or name orphans that entry's token. One developer machine was measured at five plugin entries with one ever authorized.

Splitting a connector into one entry per account would have multiplied exactly that. With the helper no entry needs OAuth at all, which is why the two changes shipped together rather than in sequence.

State

Piece
headersHelper on the shared config, renamed per dialect done
superset auth mcp-headers done
One entry per account, pinned, named by external user id done
Desktop and host-service both passing connections and the helper path done
A config rewrite when an account connects or disconnects done
The helper omitted on a cloud box, which needs none done
The CLI writing MCP entries from plugins install, connect, enable, disable, uninstall and sync done

The CLI hangs off syncPlugins(), the one function every plugin mutation there already called to converge skills — so install, connect, enable, disable, uninstall and plugins sync all gained MCP entries at once, and each reaps what it should. It also refreshes the account cache from the API wherever it has a client, which is what lets a machine the desktop never ran on split a two-account connector at all. plugins sync itself stays offline and reads the cache.

Worth deciding

Keys and behaviours verified against each agent's own source and docs rather than assumed: Claude's headersHelper from the MCP documentation, Codex's http_headers_helper from codex-rs/config/src/mcp_types.rs and rmcp-client/src/http_headers.rs. Written from this worktree, 2026-10-01; revised 2026-10-02 once the wiring landed.