mcptest docs GitHub

OAuth access token auto-refresh

mcptest stores OAuth 2.1 access tokens on disk and refreshes them automatically when they are close to expiry or when the server returns a 401. This document covers the cache layout, the lock semantics, and the failure modes the refresh layer surfaces. The OAuth login flow that populates the cache is a separate concern; this document is about what happens after login.

Run this example. examples/server-url.yml targets an HTTP MCP server with auth, so a run exercises the on-disk token cache and auto-refresh path this page describes.

mcptest run --config examples/server-url.yml

Where the cache lives

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

One file per MCP server URL. We hash the URL into a hex digest so the filename is filesystem-friendly and one server's cache cannot collide with another's. On Unix the JSON file is chmod 0600 so other users on a shared host cannot read tokens at rest. The JSON is written atomically: contents go to <key>.tmp, then rename(2) puts the new file in place.

To use a custom cache root (for tests, or for users who want the data somewhere other than $HOME), construct the cache with TokenCache::with_root(path).

What the cache stores

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

expires_at_unix is absolute (seconds since UNIX epoch), not the relative expires_in the IdP returned. That way the refresh decision is a comparison against now, with no clock drift between login time and refresh time.

The refresh flow

auth::maybe_refresh(cache, server_url, refresher, now_unix, window) runs before every request:

  1. Read the cached token. If there is nothing on disk we cannot refresh; the caller must re-authenticate (AuthError::NoRefreshToken).
  2. If expires_at_unix - now_unix > window, return the cached token unchanged. The default window is 60 seconds; tune via the runner.
  3. Otherwise acquire the advisory file lock on <key>.lock.
  4. Re-read the cached token. Another process may already have refreshed while we were waiting on the lock; if so, return what they wrote.
  5. Call refresher.refresh(refresh_token). The refresher implementor (the runner crate) makes the HTTP call, retries transient failures up to three times with exponential backoff (1s, 2s, 4s), and maps the response into a new Token.
  6. Write the new token atomically and return it.

The lock guarantees that two concurrent mcptest runs sharing the same home directory do not both burn a refresh on the same access token. Whoever gets the lock refreshes; everyone else picks up the new token on re-read.

The 401 path

When an MCP request returns 401, the runner calls auth::should_refresh_on_response(status, token, now_unix). This returns true only when:

The age floor exists so a broken server that returns 401 even on a freshly minted access token cannot push us into a tight refresh loop.

Failure modes

FailureSurfaceCaller action
Cached file missingAuthError::NoRefreshTokenRun mcptest login.
Cached file corruptAuthError::MalformedTokenDelete the file and re-authenticate.
Refresh token expired or revokedAuthError::RefreshFailed { retryable: false }Re-authenticate.
Network or 5xx during refreshAuthError::RefreshFailed { retryable: true }The implementor already retried; surface to the user with a hint.
Lock file unavailableAuthError::LockFailedUsually a permissions issue; check that ~/.mcptest/auth/ is writable.
Home directory not resolvableAuthError::Io on $HOMESet the cache root explicitly via TokenCache::with_root.

What is not in this layer

The actual HTTP exchange against the IdP's token endpoint lives in the runner crate. mcptest-core ships only the Refresher trait so the core stays transport-free and reusable from a Python or JavaScript host. Tests in this module use a mock CountingRefresher to exercise the lock and the refresh-decision logic.