Skip to content

feat: add token refresh and rotation conformance scenarios#139

Open
jdmaturen wants to merge 2 commits intomodelcontextprotocol:mainfrom
jdmaturen:feat/token-refresh-scenarios
Open

feat: add token refresh and rotation conformance scenarios#139
jdmaturen wants to merge 2 commits intomodelcontextprotocol:mainfrom
jdmaturen:feat/token-refresh-scenarios

Conversation

@jdmaturen
Copy link
Contributor

Summary

Adds two new client auth conformance scenarios that test OAuth 2.1 refresh token behavior:

  • auth/token-refresh-basic — Tests that clients use the refresh_token grant to obtain a new access token when the current one expires (OAuth 2.1 §6). Server issues 2-second TTL access tokens + refresh token; client must detect 401, send grant_type=refresh_token, and use the new access token successfully.

  • auth/token-refresh-rotation — Same flow but the server rotates the refresh token on each use (OAuth 2.1 §6.1). Client must store the new refresh token and not reuse the old one.

Motivation

Multiple MCP client implementations have been reported as not handling token refresh reliably — leading to auth loops, death spirals, and excessive retry RPM when access tokens expire. These scenarios provide a concrete conformance check against the OAuth 2.1 refresh token spec.

Changes

File Change
token-refresh.ts New file: TokenRefreshBasicScenario and TokenRefreshRotationScenario
createAuthServer.ts issueRefreshToken, rotateRefreshTokens, accessTokenExpiresIn, onRefreshTokenRequest options; full grant_type=refresh_token handler
createServer.ts perRequestServer option (fresh MCP Server per request, needed because server.close() on response end breaks subsequent requests across token boundaries)
mockTokenVerifier.ts Token expiration tracking (issuedAt, expiresIn per token); expired tokens throw InvalidTokenError with INFO conformance check
spec-references.ts OAUTH_2_1_REFRESH_TOKEN (§6) and OAUTH_2_1_TOKEN_ROTATION (§6.1)
index.ts Register both new scenarios
everything-client.ts runTokenRefreshClient exercising the full refresh flow

Test plan

  • All 79 tests pass (77 existing + 2 new token refresh scenarios)
  • auth/token-refresh-basic passes in ~3s (2s token TTL + 1s buffer)
  • auth/token-refresh-rotation passes in ~3s
  • tsc --noEmit clean

Dependencies

Depends on #138 (InvalidTokenError fix) — without it, expired tokens return 500 instead of 401 and the refresh flow never triggers.

Made with Cursor

jdmaturen and others added 2 commits February 8, 2026 22:06
The SDK's `requireBearerAuth` middleware only converts `InvalidTokenError`
instances to HTTP 401 responses. Generic `Error` instances fall through
as HTTP 500, which prevents clients from detecting authentication failures
and initiating the OAuth refresh/re-auth flow.

This was discovered while building token refresh conformance scenarios —
the mock server was returning 500 for expired/invalid tokens instead of
the expected 401.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add two new client auth conformance scenarios that test OAuth 2.1
refresh token behavior:

- `auth/token-refresh-basic`: Tests that clients use the refresh_token
  grant to obtain a new access token when the current one expires
  (OAuth 2.1 §6). Server issues 2-second TTL access tokens + refresh
  token; client must detect 401, send grant_type=refresh_token, and
  use the new access token.

- `auth/token-refresh-rotation`: Same flow but the server rotates the
  refresh token on each use (OAuth 2.1 §6.1). Client must store the
  new refresh token and not reuse the old one.

Supporting changes:

- `createAuthServer`: Add `issueRefreshToken`, `rotateRefreshTokens`,
  `accessTokenExpiresIn`, and `onRefreshTokenRequest` options. Add
  full `grant_type=refresh_token` handler with token validation,
  rotation, and conformance checks.

- `createServer`: Add `perRequestServer` option to create a fresh MCP
  Server per request. Required for token refresh tests where requests
  span token expiry boundaries (the default behavior calls
  server.close() on response end, breaking subsequent requests).

- `mockTokenVerifier`: Add token expiration tracking (issuedAt,
  expiresIn per token). Expired tokens now throw InvalidTokenError
  with an INFO conformance check, enabling proper 401 responses.

- `spec-references`: Add OAUTH_2_1_REFRESH_TOKEN (§6) and
  OAUTH_2_1_TOKEN_ROTATION (§6.1) references.

- `everything-client`: Add `runTokenRefreshClient` that exercises the
  full refresh flow (connect → request → wait for expiry → request).

Depends on modelcontextprotocol#138 (InvalidTokenError fix).

Co-authored-by: Cursor <cursoragent@cursor.com>
@jdmaturen
Copy link
Contributor Author

jdmaturen commented Feb 9, 2026

Addressed a few issues on this PR:

Rotation test didn't actually test rotation. The client only did one refresh cycle, so a client that ignores the rotated refresh token would still pass. The server revokes the old token on rotation, but since there was only one refresh, the client never needed to prove it stored the new one. Fixed by creating a separate runTokenRefreshRotationClient handler that does two full refresh cycles (two 3s waits). The scenario's getChecks() now requires rotationAttempts.length >= 2 for SUCCESS, and explicitly fails with a clear message if only one cycle completed. The rotation test now takes ~6s instead of ~3s, still well within the 15s vitest timeout.

Dead variable. refreshedAccessTokenUsed was declared but never read or written. Removed it. (Would also fail the repo's no-unused-vars eslint rule.)

Missing idempotency guards in getChecks(). Other scenarios in the repo guard against duplicate checks if getChecks() is called more than once (e.g. resource-mismatch.ts, basic-cimd.ts). The basic scenario guarded the failure path but not the success path, and the rotation scenario had no guard at all. Added guards to both.

Wrong spec section in comments. Comments referenced §4.3.1 for token rotation but the actual section in draft-ietf-oauth-v2-1-13 is §6.1 (which matches the URL already in spec-references.ts). Fixed in three places.

Also ran prettier on all changed files. They had formatting drift from the repo's config.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 13, 2026

Open in StackBlitz

npx https://pkg.pr.new/modelcontextprotocol/conformance/@modelcontextprotocol/conformance@139

commit: 2ef97b5

Copy link
Member

@pcarleton pcarleton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generally looks great, thanks for adding this.

My one big note is let's just do the rotation example. I don't see a ton of value of a client that passes the first one but not the second one. Progress on making the client better can be gained by passing more checks out of the single scenario (e.g. pass the first set of checks, fail the 2nd one when they re-use the old token).

Reasons for fewer tests:

  • less code to maintain & update
  • these suites will often run in CI, in some cases on every push for every sdk. each test often involves spinning up its own http server which takes time. So in general we want to keep it fast.

On the 2nd bullet, we may add a "slow" test tag or something in the future to indicate that we shouldn't run it on CI, and only run occasionally or when doing certification.

@jdmaturen
Copy link
Contributor Author

That's great feedback. I'll update it. May be early next week behind a few other things.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants