Headless auth
How to authenticate against an OAuth-protected MCP server when no browser exists: a coding agent, a CI job, or any other non-interactive caller. The short version: provision a token into an env var before the loop starts, name that env var everywhere, and use mcptest doctor when a 401 or 403 appears.
The decision tree
- A token already exists (issued by the IdP, a service account, or a human who logged in elsewhere): export it into an env var and name the var via
bearer_token_env. This is the headless path; everything below shows where the name goes. - The server's IdP supports the client-credentials grant (machine to machine): declare
auth.oauthwithgrant: client_credentialsin the suite YAML. mcptest exchanges the client id and secret for a token at run time with no human involved. See Auth in tests. - The IdP advertises
device_authorization_endpoint(RFC 8628) and the machine has no browser at all (SSH box, container): runmcptest login --device. mcptest prints a verification URL and a short code, a human opens the URL on any other device and enters the code, and mcptest polls until the grant lands. See the section below. - Only the authorization-code (browser) flow exists: a human must run
mcptest login <url>once.--no-browserprints the authorization URL for copy-paste instead of opening a browser. The resulting token is cached and refreshed automatically; headless runs on the same machine reuse it.
Naming the env var
One token, one env var, four surfaces that accept the name:
# Suite YAML: the runner attaches the token to every request.
servers:
api:
url: https://mcp.example.com
auth:
bearer_token_env: MCP_TOKEN
# Imperative CLI commands take the same name as a flag.
mcptest tools --url https://mcp.example.com --bearer-token-env MCP_TOKEN
mcptest doctor --url https://mcp.example.com --bearer-token-env MCP_TOKEN
// mcp-server agent verbs (list_tools, scaffold_suite, propose_assertions, ...)
{ "url": "https://mcp.example.com", "bearer_token_env": "MCP_TOKEN" }
The value never appears in YAML, flags, or reports; only the name travels. .env files are loaded automatically (see Authentication).
Refresh mid-run (already shipped)
Tokens minted by mcptest login or the client-credentials grant are cached under ~/.mcptest/auth/ with their absolute expiry and refresh token. The runner refreshes proactively near expiry and once on a 401, under a file lock so parallel runs do not race. Nothing to configure. Details in Auth: OAuth refresh.
When no token exists: what the agent should ask for
An agent that hits the 401 below and has no way to mint a token should stop and ask the human for exactly one thing:
A bearer token for
<server URL>, exported into an env var (for exampleMCP_TOKEN), or credentials for a client-credentials grant (client id, secret env var, token URL). If only browser login exists, please runmcptest login <server URL>once on this machine.
Do not retry without new credentials, and never paste a token into YAML or a command line; pass the env var name instead.
What a failure looks like
Every front-door verb and introspection command reports an auth rejection as one actionable message, not a bare 401:
auth failed: HTTP 401 from https://mcp.example.com, server advertises
Bearer (realm="mcp"). env var `MCP_TOKEN` (named by `bearer_token_env`) is
not set or is empty in this process; export a valid token into it.
Diagnose with: mcptest doctor --url https://mcp.example.com --bearer-token-env MCP_TOKEN
The message carries the status, the scheme the server advertised in WWW-Authenticate (including RFC 9728 resource_metadata when present), which input to set, and the doctor one-liner.
Diagnosing with doctor
mcptest doctor --url https://mcp.example.com --bearer-token-env MCP_TOKEN
The AUTH layer of the report distinguishes the credential states, one hint each:
| State | Hint says |
|---|---|
| No credentials supplied | pass --bearer-token-env <VAR> or run mcptest login <url> |
| Env var named but unset or empty | the var name, and to export a token into it |
| Token sent, server says 401 | the token was rejected; mint a fresh one or re-login |
| Token sent, server says 403 | the token lacks permission; check scope or audience |
Cached mcptest login token expired | run mcptest login <url> to refresh it |
Device-code flow (RFC 8628): mcptest login --device
When the IdP advertises device_authorization_endpoint in its RFC 8414 metadata, mcptest login --device mints a token without a local browser or loopback listener:
mcptest login --device --url https://mcp.example.com
The command prints a verification URL and a short code:
To sign in, open this URL on any device:
https://idp.example.com/activate
and enter the code: WDJB-MJHT
(IdPs that return verification_uri_complete get it printed on its own line, with the code already embedded.) mcptest then polls the token endpoint with the device-code grant until the human approves:
interval. Polls are spaced by the number of seconds the IdP returned in the device authorization response, defaulting to 5 when the field is omitted.slow_down. Eachslow_downresponse stretches the interval by another 5 seconds, per RFC 8628 section 3.5.- Expiry. Polling gives up once the device code's
expires_inbudget is spent and reportsexpired_token; re-run the command for a fresh code. - Any other token-endpoint error (
access_denied,expired_tokenfrom the IdP, an unknown code) is terminal and surfaces immediately.
Discovery is the same RFC 9728 + RFC 8414 walk the browser flow uses, and the resulting token caches and refreshes exactly like a PKCE one. If the IdP does not advertise the endpoint, the command says so and suggests mcptest login --no-browser instead.
Two caveats:
- Device code is not fully headless: a human still approves once on a second device. It removes the local-browser and redirect-listener requirements, which is exactly the gap for SSH boxes and containers.
- The Dynamic Client Registration cache is keyed by server URL only. A client registered earlier for the browser flow may lack the device grant; if the IdP rejects the device authorization, re-run with
--forceto register a fresh client.