mcptest docs GitHub

Authentication

How to point mcptest at a server that requires credentials, without ever putting a secret in a YAML file or a CI log. This page is the authoritative reference for the auth surface in v1.0.

Which auth do I need?

Most authentication questions answer themselves once you know two things: what transport the server uses, and whether you control the deployment.

                ┌───────────────────────────────────┐
                │ Is the server a local subprocess  │
                │ (stdio transport) or a URL?       │
                └─────────────────┬─────────────────┘
                                  │
                ┌─────────────────┴──────────────────┐
                │                                    │
                ▼                                    ▼
          stdio target                          URL target
                │                                    │
                ▼                                    ▼
        No auth needed.                ┌────────────────────┐
        The transport is               │ Is the URL on a    │
        the trust boundary.            │ developer machine, │
        Skip the rest of               │ or behind real     │
        this page.                     │ auth?              │
                                       └─────────┬──────────┘
                                                 │
                              ┌──────────────────┴──────────────────┐
                              │                                     │
                              ▼                                     ▼
                  Open dev URL (no auth):                 Production / staging
                  http://localhost:8000,                  URL behind real auth
                  ngrok preview without                   (Cloudflare Access,
                  protection. Skip the rest               IAP, OAuth, API
                  of this page.                           gateway, custom).
                                                                    │
                                                                    ▼
                                                       ┌──────────────────────┐
                                                       │ Does the server      │
                                                       │ accept a long-lived  │
                                                       │ bearer token, or do  │
                                                       │ you log in to get a  │
                                                       │ short-lived one?     │
                                                       └──────────┬───────────┘
                                                                  │
                                              ┌───────────────────┴─────────────────┐
                                              │                                     │
                                              ▼                                     ▼
                                  Long-lived bearer token                Login required
                                  (service account, PAT,                 (interactive user,
                                  CI deploy key).                        SSO behind it).
                                              │                                     │
                                              ▼                                     ▼
                              Use `auth.bearer_token_env:`           Use `auth.oauth:` plus
                              and put the value in an                `mcptest login` to
                              env var. See                           populate the token
                              [Bearer token via env var]              cache. See
                              (#bearer-token-via-env-var).            [OAuth 2.1 + PKCE]
                                                                     (#oauth-21--pkce).

The two leaves under "production URL" are not mutually exclusive across servers. A suite that talks to two URL servers may use a bearer token for one and OAuth for the other. Each entry under servers: configures its own auth.

If the same server accepts both bearer tokens and OAuth, prefer the bearer form. It is simpler, has fewer moving parts, and runs unattended in CI without a logged-in human anywhere in the loop. Save OAuth for servers that demand it.

Bearer token via env var

The most common production setup is a server that accepts a long-lived bearer token. The token lives in an env var; the YAML points at the env var by name. mcptest reads the var at request time and sends Authorization: Bearer <value> for you.

servers:
  remote_api:
    url: "https://mcp.example.com/v1"
    auth:
      bearer_token_env: "MCPTEST_REMOTE_API_TOKEN"

Required field:

The runner promotes the resolved value to the redaction registry the moment it reads it, so the token never appears in reporter output, cassette dumps, or doctor logs. The redaction policy is documented at docs/security/redaction.md.

Env var setup, local

For local development, export the var in your shell or put it in a .env file next to mcptest.yml:

# in your shell
export MCPTEST_REMOTE_API_TOKEN="sk-live-..."
mcptest run

# or in a .env file (loaded automatically by dotenvy)
echo 'MCPTEST_REMOTE_API_TOKEN=sk-live-...' >> .env
mcptest run

The .env file should be in your .gitignore. Commit a .env.example with the variable names but no values so other developers know which vars they need.

CI integration

mcptest reads the env var from whatever process the CI runner spawned it under. Every major CI platform has a way to expose secrets as env vars. Three worked examples follow.

GitHub Actions

# .github/workflows/mcptest.yml
name: mcptest
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo install mcptest --locked
      - run: mcptest run
        env:
          MCPTEST_REMOTE_API_TOKEN: ${{ secrets.MCPTEST_REMOTE_API_TOKEN }}

Define the secret at the repository level under Settings, Secrets and variables, Actions. The expression ${{ secrets.NAME }} is the GitHub Actions equivalent of "read this from the encrypted secret store and expose it to this step only."

GitLab CI

# .gitlab-ci.yml
mcptest:
  image: rust:1.78
  script:
    - cargo install mcptest --locked
    - mcptest run
  variables:
    MCPTEST_REMOTE_API_TOKEN: $MCPTEST_REMOTE_API_TOKEN

GitLab masks variables marked "Masked" in the project's CI settings. Mark every secret variable as masked so it does not echo into job logs.

CircleCI

# .circleci/config.yml
version: 2.1
jobs:
  mcptest:
    docker:
      - image: cimg/rust:1.78
    steps:
      - checkout
      - run: cargo install mcptest --locked
      - run: mcptest run

workflows:
  test:
    jobs:
      - mcptest:
          context: mcptest-secrets

The context block (here mcptest-secrets) is a CircleCI primitive for sharing secrets across jobs. Define the context once in the organization settings, list the env var there, and any job that opts into the context inherits it.

Token rotation

Long-lived tokens should rotate on a schedule. mcptest does not rotate for you; it reads whatever value the env var holds at request time. The recommended pattern is to issue a new token, set it in your CI secret store, redeploy the env, and revoke the old one a day or two later. The runner picks up the new value on the next run with no code change.

If you want the rotation to be invisible to your YAML (rotating tokens with versioned names like MCPTEST_TOKEN_V3), use ${SECRET_VAR_NAME} interpolation in the bearer_token_env field itself:

servers:
  api:
    url: "https://api.example.com/mcp"
    auth:
      bearer_token_env: "${MCPTEST_ACTIVE_TOKEN_VAR}"

Now the env var named in MCPTEST_ACTIVE_TOKEN_VAR is the one that gets read. Change MCPTEST_ACTIVE_TOKEN_VAR=MCPTEST_TOKEN_V4 to switch.

OAuth 2.1 + PKCE

For URL servers behind an interactive identity provider, mcptest implements OAuth 2.1 with PKCE per the MCP specification. The model:

  1. You run mcptest login <server-name> once per developer machine.
  2. The runner opens a browser, walks the OAuth flow with PKCE, and writes the resulting access and refresh tokens to a per-server file under ~/.mcptest/auth/.
  3. Subsequent mcptest run invocations read the cache, refresh on 401, and present the access token as a bearer header on each request.

The login flow lands with the runner work. The token cache and refresh-on-401 layer already shipped ( in docs/auth-refresh.md), so the cache layout this page describes is real today even though the login command is not yet wired up. Author your YAML against the shape below; it will work the day the login command lands.

The YAML side

servers:
  remote_api:
    url: "https://mcp.example.com/v1"
    auth:
      oauth:
        client_id_env: "MCPTEST_OAUTH_CLIENT_ID"
        authorization_url: "https://auth.example.com/oauth/authorize"
        token_url: "https://auth.example.com/oauth/token"
        scopes:
          - "mcp:read"
          - "mcp:invoke"

Required fields under oauth:

Optional:

Dynamic Client Registration (RFC 7591)

When your authorization server supports RFC 7591, mcptest will register itself automatically on first login. You skip the manual "create an app" step on the IdP, and client_id_env is populated behind the scenes; you only need to point authorization_url and token_url at the right endpoints.

The intended flow:

  1. mcptest login remote_api
  2. The CLI POSTs an RFC 7591 client metadata document to the server's registration endpoint (discovered via the standard /.well-known/oauth-authorization-server metadata).
  3. The IdP returns a client ID (and optionally a client secret), which mcptest writes into the token cache alongside the access and refresh tokens.
  4. The PKCE flow proceeds with the freshly issued client ID.

If RFC 7591 is unavailable on your IdP, fall back to the manual "create an app, paste the client ID into an env var" workflow. The two paths coexist; the runner uses whichever metadata it finds.

The mcptest login command

# log into a single server
mcptest login remote_api

# log into every URL server that uses oauth
mcptest login --all

# log out (delete the cached tokens)
mcptest logout remote_api

Behind the scenes mcptest login:

  1. Reads the oauth: block for the named server from your YAML.
  2. Spawns a temporary listener on 127.0.0.1:<random port> to catch the redirect.
  3. Opens your default browser at the authorization URL with a code_challenge derived from a fresh PKCE verifier.
  4. Catches the redirect, exchanges the code at the token endpoint, and writes the result to the per-server cache file.

The default browser opens via xdg-open on Linux, open on macOS, and start on Windows. If the browser cannot open (headless CI, locked-down desktop), the CLI prints the authorization URL and waits for you to paste back the redirected URL.

Refresh on 401 (shipped)

While mcptest run is executing, the cached access token may expire mid-suite. The refresh layer handles this transparently:

  1. The server returns 401.
  2. The runner reads the cached refresh token.
  3. The runner POSTs to token_url with grant_type=refresh_token and the cached refresh token.
  4. On success, the cache is rewritten atomically and the original request retries once with the new access token.
  5. On failure (refresh token revoked, IdP unreachable, server still returns 401 with the new token), the runner aborts with a clear error and a hint to re-run mcptest login.

The refresh path holds an advisory file lock so two concurrent runs against the same server do not duplicate the refresh request. See docs/auth-refresh.md for the full mechanism.

Token cache at ~/.mcptest/auth/

~/.mcptest/auth/
  <sha256(server_url)>.json   # cached Token document, chmod 0600 on Unix
  <sha256(server_url)>.lock   # advisory lock, sibling to the cache

One file per MCP server URL. The filename is a hex SHA-256 of the URL so two servers cannot collide and the filename is filesystem-safe.

The JSON content (see ):

{
  "access_token": "...",
  "refresh_token": "...",
  "expires_at_unix": 1700000000,
  "token_type": "Bearer",
  "scope": "mcp:read",
  "last_refreshed": "2026-05-15T00:00:00Z"
}

Move the cache to a different root with TokenCache::with_root(path) in tests, or by setting MCPTEST_AUTH_DIR in the environment.

OAuth in CI

CI is the awkward case for OAuth: nobody is sitting at a browser when a build runs. Three workable strategies:

  1. Pre-populate the cache. Log in once on a controlled host, copy ~/.mcptest/auth/<hash>.json into a CI secret, and write it to the runner before mcptest run. The refresh path will keep it alive for as long as the refresh token is valid.
  2. Switch to a bearer token for CI. Many IdPs let you mint a long-lived machine token alongside the interactive OAuth flow. Use the bearer-token form for CI, and the OAuth form for local dev. Two servers: entries pointing at the same URL, picked via --profile.
  3. Use a CI-only OAuth client with service account credentials. Some IdPs support a non-interactive grant for machine clients (client credentials flow). Configure the OAuth block to use that grant type; no human in the loop. v1.0 only ships the interactive PKCE flow; the client credentials grant is on the roadmap.

Pick whichever fits your IdP and audit posture.

Custom headers

For auth schemes that are not bearer tokens or OAuth (Cloudflare Access, GCP IAP signed JWTs, AWS API Gateway API keys), use the headers: block on the server. The headers: block sits at the server level, not under auth:, because it serves both auth and non-auth use cases (tenant routing, tracing IDs). The full shape is documented in docs/yaml-reference.md#custom-headers-and-http-transport. The relevant rules for auth:

servers:
  protected:
    url: "https://mcp.example.com/v1"
    headers:
      X-API-Key:
        env: PROTECTED_API_KEY
      CF-Access-Client-Id:
        env: CF_ACCESS_CLIENT_ID
      CF-Access-Client-Secret:
        env: CF_ACCESS_CLIENT_SECRET

Each entry maps a header name to a value. The value is either a literal string (with ${VAR} interpolation) or an object {env: NAME} that the runner reads from the environment at connect time.

Literal vs env: suffix: when to use which

Picking between literal and env-backed comes down to one rule: if revealing the value in the YAML or a reporter line would be bad, use the env form. Otherwise use literal.

Header purposeUse literalUse env
Tenant identifier (X-Tenant: acme)yesno
Plan or feature flag (X-Plan: enterprise)yesno
Trace ID (X-Trace-Id: ${run_id})yes (via interpolation)no
API key / shared secretnoyes
Service account JWTnoyes
Signed access tokennoyes
OIDC client credentialsnoyes

Values read via env: go through the same redaction registry as bearer tokens, so they never appear in reporter output. Literal values are not redacted.

A useful rule of thumb: if you would not paste the value into a public Slack channel, use env:.

The headers block is forbidden from setting Authorization or Proxy-Authorization; use the auth: block for those. The schema rejects them at validation time.

The behind-the-scenes ticket is .

CLI flags for headers

Two global flags map to the same surface so a single YAML can target multiple environments. Both are repeatable.

Useful when the same suite runs against staging (one set of headers) and production (a different set) without forking the YAML.

Troubleshooting

"OAuth flow hung"

Symptoms: mcptest login opens a browser, you authorize, the page spins indefinitely.

Causes and fixes:

"401 after login"

Symptoms: mcptest login succeeds, you can see the cache file, but mcptest run immediately returns 401 from the server.

Causes and fixes:

"Token expired mid-run"

Symptoms: the suite runs partway, then fails with a token-expired error.

This is what the refresh layer is for. If you see this error, one of three things happened:

The runner's behavior on refresh failure is to abort the run with a clear error rather than continue with stale credentials. This is deliberate.

"Secret leaked in log"

Symptoms: you see what looks like a bearer token in --verbose output or in a JUnit report.

The redaction layer should prevent this. If you see a raw secret, file a GitHub issue against the mcptest repo with the smallest reproducer you can produce. In the meantime:

Worked examples

Five concrete configurations for common edge production setups.

Cloudflare Access

Cloudflare Access protects an internal URL with two custom headers issued from a service token.

servers:
  protected:
    url: "https://mcp.internal.example.com/v1"
    headers:
      CF-Access-Client-Id:
        env: CF_ACCESS_CLIENT_ID
      CF-Access-Client-Secret:
        env: CF_ACCESS_CLIENT_SECRET

Set CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET in your env. Both values are treated as secrets and never appear in reporter output.

Google Cloud IAP

GCP Identity-Aware Proxy fronts a Cloud Run service with a JWT header. Mint the JWT however your shop normally does, drop it into an env var, and reference it:

servers:
  gcp:
    url: "https://iap.example.com/mcp"
    headers:
      Proxy-Authorization-IAP:
        env: GCP_IAP_TOKEN
    http:
      timeout: 60s

The 60-second timeout is generous; IAP cold starts can run long.

AWS API Gateway

AWS API Gateway routes a custom domain to a Lambda-backed MCP endpoint with an API key header.

servers:
  awsg:
    url: "https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp"
    headers:
      x-api-key:
        env: AWSG_API_KEY

API Gateway is picky about header case in some configurations. The example above uses the lowercase form AWS publishes; if your stage is case-sensitive and rejects the request, try X-Api-Key.

Multi-tenant SaaS

A multi-tenant SaaS that scopes requests by tenant header plus a shared bearer token.

servers:
  saas:
    url: "https://api.example.com/mcp"
    auth:
      bearer_token_env: "SAAS_API_TOKEN"
    headers:
      X-Tenant: "acme"
      X-Plan: "enterprise"

The bearer token is shared across tenants; the tenant header is the literal tenant slug. Switch tenants by overriding --header X-Tenant=other on the CLI rather than editing the YAML.

Internal CA bundle

An internal endpoint signed by a corporate CA. No bearer needed, but the TLS verification needs the bundle:

servers:
  internal:
    url: "https://mcp.corp.example.com/v1"
    http:
      tls:
        ca_bundle_path: "/etc/ssl/corp-internal-ca.pem"
        min_version: "1.3"

This is "auth" in the sense that the TLS handshake is the trust anchor. If your internal endpoint also wants a token, add a normal auth: block on top.

Transport-level client auth

Three transport-level schemes sign or authenticate the connection itself, rather than carrying a token in a header. They compose with the header-level schemes above: an mTLS connection can still carry a bearer token, and a SigV4-signed request can still carry static headers.

mTLS (client certificates)

For a server that requires a client certificate at the TLS handshake. mcptest loads the cert and key and presents them on every request.

servers:
  internal:
    url: "https://mcp.corp.example.com/v1"
    http:
      tls:
        mtls:
          client_cert_path: "/etc/mcptest/client.pem"
          client_key_path: "/etc/mcptest/client.key"
          ca_bundle_path: "/etc/ssl/corp-internal-ca.pem"

What it is for: mutual-TLS endpoints behind a corporate CA or a service mesh that pins client identity to a certificate.

Fields under mtls:

AWS SigV4 signed requests

For an MCP endpoint fronted by API Gateway or a Lambda Function URL that authorizes with AWS IAM. mcptest signs each request with SigV4.

servers:
  awsg:
    url: "https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp"
    auth:
      aws_sigv4:
        region: "us-east-1"
        service: "execute-api"
        credentials:
          source: from_environment

What it is for: IAM-authorized API Gateway / Lambda endpoints, signed per request so the time-bound signature is always fresh.

Fields under aws_sigv4:

Named profiles

The profile source resolves credentials from a named profile in your AWS files, the same files the AWS CLI reads:

servers:
  awsg:
    url: "https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp"
    auth:
      aws_sigv4:
        service: "execute-api"
        credentials:
          source: profile
          name: prod

mcptest reads the access key, secret key, optional session token, and region from ~/.aws/credentials, and falls back to ~/.aws/config for the region. Set AWS_SHARED_CREDENTIALS_FILE to point at a different credentials file and AWS_CONFIG_FILE for a different config file; mcptest honors both.

The credentials file uses bare [profile-name] sections:

[prod]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
aws_session_token = ...
region = us-east-1

The config file uses [profile profile-name] sections (and a bare [default] for the default profile):

[profile prod]
region = us-east-1

You can omit region from the YAML when the profile supplies one: the profile region fills an empty config region, and an explicit region in the YAML or --aws-sigv4-region still wins. The secret key and session token are read at sign time and never logged.

On the command line, --profile <NAME> selects the same source:

mcptest run --profile prod --aws-sigv4-region us-east-1 --service execute-api

Web Bot Auth (signed HTTP message bot identity)

For a server that verifies an RFC 9421 HTTP Message Signature to identify the agent. mcptest signs each request with an Ed25519 key.

servers:
  bot:
    url: "https://mcp.example.com/v1"
    auth:
      web_bot_auth:
        key_path: "/etc/mcptest/bot.ed25519"
        directory_url: "https://mcptest.sh/.well-known/http-message-signatures-directory"
        signature_agent: "https://mcptest.sh"

What it is for: presenting a verifiable bot identity to origins that check Web Bot Auth signatures.

Fields under web_bot_auth:

Both Ed25519 and RSA-PSS (rsa-pss-sha512) are supported. Ed25519 is the default and the draft's recommendation for new deployments; choose rsa-pss when you already manage RSA keys. For RSA-PSS, point key_path or key_env at an RSA PKCS#8 PEM and set algorithm: rsa-pss:

servers:
  bot:
    url: "https://mcp.example.com/v1"
    auth:
      web_bot_auth:
        key_env: "MCPTEST_BOT_RSA_KEY"
        directory_url: "https://mcptest.sh/.well-known/http-message-signatures-directory"
        signature_agent: "https://mcptest.sh"
        algorithm: rsa-pss

Doctor pre-flight (local, no network)

mcptest doctor runs a local pre-flight for any transport-level auth scheme your config declares. It validates from files and environment only, makes no network call, and never prints a secret. Run it before a real run to catch a misconfiguration up front:

mcptest doctor

What it checks, per scheme:

The pre-flight only reports non-secret facts: file paths, expiry dates, the derived public-key id, the algorithm name, and "credentials found in environment". Secret access keys, private keys, and signing keys never appear. A failed check makes mcptest doctor exit non-zero.

Live probes (opt-in, network)

By default the pre-flight is offline. Add --probe to make one real network call per scheme so you can confirm the credentials actually authenticate, not just that they parse:

mcptest doctor --probe

What each probe does:

Probe rows print under a live probes (network) heading, one line per scheme, and report only a status code or a secret-free transport error. A failed probe makes mcptest doctor exit non-zero. Leave --probe off to keep the run fully offline and deterministic.

CLI flags

Every field above can also come from a mcptest run flag. A flag overrides the matching YAML field (CLI beats YAML), or populates the scheme when the YAML did not declare one. Secrets still come from environment variables: the *-env / *-key-env flags name a variable, they never take a raw secret as a value.

FlagOverrides
--client-cert <PATH>http.tls.mtls.client_cert_path
--client-key <PATH>http.tls.mtls.client_key_path
--client-key-env <VAR>http.tls.mtls.client_key_env
--ca-bundle <PATH>http.tls.mtls.ca_bundle_path
--aws-sigv4-region <REGION>auth.aws_sigv4.region
--service <NAME>auth.aws_sigv4.service
--profile <NAME>auth.aws_sigv4.credentials named-profile source
--webbot-key <PATH>auth.web_bot_auth.key_path
--webbot-key-env <VAR>auth.web_bot_auth.key_env
--webbot-agent <URL>auth.web_bot_auth.signature_agent
--webbot-algorithm <ALG>auth.web_bot_auth.algorithm

--profile <NAME> selects the named-profile credential source. The keys resolve from ~/.aws/credentials (and ~/.aws/config for the region) at sign time, never from the command line. See "Named profiles" above.

A flag combination that produces an invalid block (a cert with no key, an empty SigV4 region, an unknown Web Bot Auth algorithm) is rejected before the run starts, with the same validator that runs at config load.

Web Bot Auth key directory

A server operator publishes a .well-known/http-message-signatures-directory document so verifiers can find a bot's public key. mcptest is a client, but it can build that document for your signing key so you can publish it:

mcptest web-bot-auth directory --key bot.ed25519
# RSA-PSS keys: add --algorithm rsa-pss.
# Env-sourced key: --key-env MCPTEST_BOT_KEY.

The command prints a JWK Set ({"keys": [...]}) carrying only the public key and the same non-secret key id the signer advertises in Signature-Input. The private key is never printed. See examples/auth-fixtures/ for a full local round trip that verifies the signature against this directory.

Deferred polish (still open)

The schemes above sign and connect correctly today. RSA-PSS for Web Bot Auth (algorithm: rsa-pss), SigV4 named profiles, live doctor probes (--probe), and the key-directory command all shipped. Some surrounding polish is still open:

Open a GitHub issue if you need one of these.

See also