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:
- Read the cached token. If there is nothing on disk we cannot refresh; the caller must re-authenticate (
AuthError::NoRefreshToken). - If
expires_at_unix - now_unix > window, return the cached token unchanged. The default window is 60 seconds; tune via the runner. - Otherwise acquire the advisory file lock on
<key>.lock. - Re-read the cached token. Another process may already have refreshed while we were waiting on the lock; if so, return what they wrote.
- 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 newToken. - 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 status is 401,
- the token has a
refresh_token, and - the token was written more than 60 seconds ago.
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
| Failure | Surface | Caller action |
|---|---|---|
| Cached file missing | AuthError::NoRefreshToken | Run mcptest login. |
| Cached file corrupt | AuthError::MalformedToken | Delete the file and re-authenticate. |
| Refresh token expired or revoked | AuthError::RefreshFailed { retryable: false } | Re-authenticate. |
| Network or 5xx during refresh | AuthError::RefreshFailed { retryable: true } | The implementor already retried; surface to the user with a hint. |
| Lock file unavailable | AuthError::LockFailed | Usually a permissions issue; check that ~/.mcptest/auth/ is writable. |
| Home directory not resolvable | AuthError::Io on $HOME | Set 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.