You add a protected MCP server to a client, the server answers 401, and the browser login
that follows works until the access token expires. Then the client marks the server as
needing sign-in again, or every connection fails with invalid_client until somebody finds
the client’s credential cache and deletes it. The client shows none of the six requests in
the flow, so nothing says which one failed.
What you get
You will end up with a client that completes the MCP authorization flow, keeps its registration and tokens in one file, refreshes with no browser, and recovers when the server forgets it. This is for you if you build or operate an MCP client.
Short answer
Give the MCP TypeScript SDK’s Streamable HTTP transport an OAuthClientProvider that stores
its client registration, tokens, and PKCE verifier on disk, and let the SDK walk the
specified flow. The 401 names the protected resource metadata, that names the authorization
server, the client registers or presents a client ID, and the authorization code exchange
with PKCE returns a refresh token. Implement invalidateCredentials so a registration the
server has forgotten is dropped instead of retried.
You will need
Node 22 or later, the MCP TypeScript SDK, and Zod, its peer dependency. An MCP server that
publishes RFC 9728 metadata and answers 401
with a WWW-Authenticate header, and an authorization server with
RFC 8414 metadata. Verified 2026-10-21
against Node 22.22.0, @modelcontextprotocol/sdk 1.32.1, and zod 4.6.7. The sample runs
both servers in one process on 127.0.0.1, approves every authorization for one fixed user,
and calls fetch where a desktop client would open the browser.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A static bearer token in client headers | A server that issues personal access tokens, a job with nobody at a keyboard, or a server with no authorization server behind it | One secret in every copy of the config, no refresh, no per-user identity, and a revocation that stops every copy at once | A person uses the client and the server advertises an authorization server |
| MCP OAuth 2.1 with dynamic client registration | A server that advertises an authorization server, and a person who can sign in once | A loopback listener, a browser step, and a cache of registration and tokens that outlives the server’s memory of them | The authorization server registers nothing on demand and hands out client IDs by hand |
mcp-remote holding the credential locally | A client that speaks only stdio, or one whose own OAuth support cannot reach the server | A Node process per server, a token store under ~/.mcp-auth to clear when a login goes stale, and a flow you debug from its log file | The client runs the flow itself |
The specified flow costs a loopback listener, a browser step, and a credential cache, and
pays with a token that names one person and can be revoked for that person alone. A pasted
token skips all three and leaves one secret in every copy of the config. mcp-remote runs
the flow for a client that cannot, in a process of its own, with a cache of its own.
Let the 401 start the flow
The authorization specification
puts the first move on the server. A request with no token gets a 401 whose
WWW-Authenticate header carries resource_metadata, the URL of the server’s RFC 9728
document, and the scope the request needs.
RFC 6750 section 3 adds that a request with
no credentials gets no error code. The resource server below does that, and answers
invalid_token to anything expired, unknown, or minted for another resource.
const method = req.method.padEnd(4)
const [scheme, value] = (req.headers.authorization ?? '').split(' ')
if (!/^bearer$/i.test(scheme) || !value) {
// RFC 6750 3: a request with no token gets no error code. The metadata URL and the
// scope are the whole message, and they are what a client needs to start the flow.
const header = challenge(res, 401, { resource_metadata: metadataUrl, scope: SCOPE }, 'a bearer token is required')
return log(`RS ${method} /mcp 401 WWW-Authenticate: ${header}`)
}
const info = verifyToken(value)
if (!info.active) {
challenge(res, 401, { error: 'invalid_token', resource_metadata: metadataUrl, scope: SCOPE }, 'the token is unknown, expired, or revoked')
return log(`RS ${method} /mcp 401 WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="${SCOPE}"`)
}
// RFC 8707 2: a token minted for another resource is not valid here, however well formed.
if (info.resource !== canonical) {
challenge(res, 401, { error: 'invalid_token', resource_metadata: metadataUrl, scope: SCOPE }, `the token was issued for ${info.resource}, this server is ${canonical}`)
return log(`RS ${method} /mcp 401 WWW-Authenticate: Bearer error="invalid_token" (audience ${info.resource})`)
}
The metadata document names the authorization server, and from there the SDK’s
auth()
takes every step. It reads the RFC 8414 metadata, registers under
RFC 7591 because that metadata lists a
registration_endpoint, and asks for a code with a
PKCE challenge and the
RFC 8707 resource parameter. The demo
prints each request as the servers see it.
node demo.mjs
first connection, empty cache
RS POST /mcp 401 WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:38271/.well-known/oauth-protected-resource/mcp", scope="mcp:tools"
RS GET /.well-known/oauth-protected-resource/mcp 200 authorization_servers: [http://127.0.0.1:46253]
AS GET /.well-known/oauth-authorization-server 200 registration_endpoint, code_challenge_methods_supported: [S256]
AS POST /register 201 client_id=a283b754-3cf4-446c-91c8-90118803c629 token_endpoint_auth_method=none
AS GET /authorize 302 code_challenge_method=S256 resource=http://127.0.0.1:38271/mcp ada@example.com approved
CB GET /callback 200 code=442009fd state and iss present: true
AS POST /token 200 grant_type=authorization_code expires_in=3600 refresh_token issued
RS POST /mcp 200 initialize user ada@example.com
RS POST /mcp 202 notifications/initialized user ada@example.com
RS POST /mcp 200 tools/call user ada@example.com
whoami -> ada@example.com via client a283b754-3cf4-446c-91c8-90118803c629, scope mcp:tools
cache file now holds: clientInformation, codeVerifier, discoveryState, tokens
Three values in the transcript carry the security of the flow.
code_challenge_method=S256 binds the code to the client that asked for it. resource is
the canonical URL of the MCP server, and the resource server refuses a token bound to any
other URL. token_endpoint_auth_method=none records that this client has no secret, because
a client on a laptop cannot keep one.
The transcript shows dynamic registration because this authorization server’s metadata
lists a registration_endpoint without client_id_metadata_document_supported. The
2026-07-28 revision marks that path deprecated in favor of
Client ID Metadata Documents,
where the client ID is an HTTPS URL to a document the client publishes about itself.1
The SDK takes that path when the server advertises it.
Finish the browser step on a loopback listener
When the SDK reaches the authorization request it calls the provider’s
redirectToAuthorization with the URL, and the transport throws UnauthorizedError. A
desktop client opens the system browser there; the demo calls fetch, which follows the
redirect as a browser would. The client code owns the landing: a loopback listener, the
checks on what arrives, the exchange, and a second connection, because a started transport
cannot be started again.
export async function connectWithOAuth(serverUrl, provider, { log = () => {}, name = 'notes-cli', timeoutMs } = {}) {
const callback = await loopbackCallback({ log, timeoutMs })
provider.redirectUrl = callback.redirectUrl
const client = new Client({ name, version: '1.0.0' })
try {
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider })
try {
await client.connect(transport)
return { client, transport }
} catch (err) {
if (!(err instanceof UnauthorizedError)) throw err
}
// The SDK has sent the browser to the authorization server and thrown. Wait for the
// code to arrive on the loopback listener, check it is the answer to this request
// and from the issuer that was discovered, exchange it, and connect on a new transport.
const query = await callback.params()
if (query.has('error')) throw new Error(`authorization refused: ${query.get('error')} (${query.get('error_description') ?? ''})`)
if (query.get('state') !== provider.expectedState()) throw new Error('state mismatch: the callback is not the answer to this request')
// RFC 9207 2.4: an iss that is present must be the issuer recorded at discovery, and one
// that is absent is refused when the server's metadata says it always sends one.
const metadata = provider.discoveryState()?.authorizationServerMetadata
const issRequired = metadata?.authorization_response_iss_parameter_supported === true
if (query.has('iss') ? query.get('iss') !== metadata?.issuer : issRequired) {
throw new Error(`iss mismatch: ${query.get('iss') ?? 'no iss'} answered, ${metadata?.issuer} was asked`)
}
await transport.finishAuth(query.get('code'))
const authorized = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider })
await client.connect(authorized)
return { client, transport: authorized }
} finally {
callback.close()
}
The state check ties the callback to the request that started it. The iss check is
RFC 9207, added in that revision: a client
that recorded the discovered issuer refuses a code from anyone else. It also refuses a code
with no iss when the server’s metadata says it always sends one, as this server’s does.
The listener’s port is whatever the operating system had free, so it differs from the one in the cached registration. RFC 8252 section 7.3 requires an authorization server to allow any port on a loopback redirect URI,2 and the sample’s server does.
export function redirectUriMatches(registered, presented) {
const a = new URL(registered)
const b = new URL(presented)
if (a.protocol === 'http:' && LOOPBACK.has(a.hostname)) b.port = a.port
return a.href === b.href
}
Keep the registration and the tokens in one file
Every value the SDK asks the provider to save goes in one JSON file: the registration, the
tokens, the PKCE verifier for the authorization in progress, and the discovery results. The
file holds a refresh token, so the provider writes it with mode 0600. The
SDK stamps the registration and the tokens with the issuer they came from and refuses to
present them to another authorization server, as the specification requires.
export class FileOAuthProvider {
#file
redirectUrl
constructor({ file, openBrowser, clientName = 'notes-cli', forgetOnInvalidClient = true }) {
this.#file = file
this.openBrowser = openBrowser
this.clientName = clientName
// The SDK calls invalidateCredentials when the authorization server answers
// invalid_client or invalid_grant, then tries once more from the top. A provider
// without it keeps the stale entry, which is what pitfall.mjs shows.
if (forgetOnInvalidClient) this.invalidateCredentials = (scope) => this.forget(scope)
}
A second run from the same file, once the access token has expired, looks like this:
node demo.mjs
the next morning: access token expired, same cache file
RS POST /mcp 401 WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
AS POST /token 200 grant_type=refresh_token expires_in=3600 refresh_token rotated
RS POST /mcp 200 initialize user ada@example.com
RS POST /mcp 202 notifications/initialized user ada@example.com
RS POST /mcp 200 tools/call user ada@example.com
whoami -> ada@example.com via client a283b754-3cf4-446c-91c8-90118803c629, scope mcp:tools
No registration, no authorization request, no browser. The expired token draws an
invalid_token challenge, the SDK presents the refresh token, and the authorization server
rotates it, so yesterday’s refresh token stops working the moment the replacement is written.
OAuth 2.1 section 4.3 asks
for rotation or sender-constraining whenever the client cannot authenticate, which a public
client cannot.3
Measure the shortcut
A token pasted into the client’s configuration skips the flow. Claude Code takes it as
--header on claude mcp add, and Cursor as a
headers object in mcp.json. VS Code takes a
headers field
whose value can be an ${input:...} the editor prompts for and stores. The demo mints one
such token for a service identity and hands it to two clients.
node demo.mjs
the shortcut: one static token pasted into two client configs
RS POST /mcp 200 initialize user ci-bot@example.com
RS POST /mcp 202 notifications/initialized user ci-bot@example.com
RS POST /mcp 200 tools/call user ci-bot@example.com
laptop whoami -> ci-bot@example.com via client static-token, scope mcp:tools
RS POST /mcp 200 initialize user ci-bot@example.com
RS POST /mcp 202 notifications/initialized user ci-bot@example.com
RS POST /mcp 200 tools/call user ci-bot@example.com
build-agent whoami -> ci-bot@example.com via client static-token, scope mcp:tools
token revoked at the authorization server
RS POST /mcp 401 WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
laptop StreamableHTTPError: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"the token is unknown, expired, or revoked"}
RS POST /mcp 401 WWW-Authenticate: Bearer error="invalid_token", resource_metadata=..., scope="mcp:tools"
build-agent StreamableHTTPError: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"the token is unknown, expired, or revoked"}
Both copies are ci-bot@example.com to the server, so an audit log cannot tell the laptop
from the build agent. One revocation stops both, and with no authProvider on the transport
there is no refresh token to recover with. Claude Code’s documentation adds that a
configured Authorization header disables its OAuth path for that server.
Check it worked
Run the suite. Nine tests cover the flow, the refresh, both outcomes of a lost registration,
the PKCE and audience refusals, the static token, and the loopback port rule. Four more cover
the cache file’s mode, a missing or foreign iss, a sign-in nobody finishes, and a request
from a page on another site.
node --test client.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 941.703395
The refresh test is the one to read: after the access token expires, the transcript holds
no /authorize, no /register, and one /token request with grant_type=refresh_token,
and the cached refresh token changed.
When it goes wrong
Every connection fails with invalid_client after the server was redeployed, and the browser
never opens. The authorization server kept its registrations in memory and lost them; the
client’s cache still holds the client_id and a refresh token bound to it. The SDK reads the
refusal, calls the provider’s invalidateCredentials, and starts over. A provider without
that method keeps the stale entry, so the SDK’s one retry gets the same answer.
node pitfall.mjs
registrations the authorization server still knows: 0
a provider without invalidateCredentials, stale cache
attempt 1 InvalidClientError: unknown client_id
AS POST /token 400 error=invalid_client grant_type=refresh_token
AS POST /token 400 error=invalid_client grant_type=refresh_token
attempt 2 InvalidClientError: unknown client_id
AS POST /token 400 error=invalid_client grant_type=refresh_token
AS POST /token 400 error=invalid_client grant_type=refresh_token
the cache file is deleted by hand
attempt 3 connected, whoami -> ada@example.com via client 8e9732fa-882a-4006-b010-a57ff3ff69ab, scope mcp:tools
AS GET /.well-known/oauth-authorization-server 200 registration_endpoint, code_challenge_methods_supported: [S256]
AS POST /register 201 client_id=8e9732fa-882a-4006-b010-a57ff3ff69ab token_endpoint_auth_method=none
AS GET /authorize 302 code_challenge_method=S256 resource=http://127.0.0.1:41981/mcp ada@example.com approved
AS POST /token 200 grant_type=authorization_code expires_in=3600 refresh_token issued
a provider with invalidateCredentials, the same stale cache
attempt 1 connected, whoami -> ada@example.com via client 247bae36-044a-4f1e-b04a-5b9b64293d51, scope mcp:tools
AS POST /token 400 error=invalid_client grant_type=refresh_token
AS GET /.well-known/oauth-authorization-server 200 registration_endpoint, code_challenge_methods_supported: [S256]
AS POST /register 201 client_id=247bae36-044a-4f1e-b04a-5b9b64293d51 token_endpoint_auth_method=none
AS GET /authorize 302 code_challenge_method=S256 resource=http://127.0.0.1:41981/mcp ada@example.com approved
AS POST /token 200 grant_type=authorization_code expires_in=3600 refresh_token issued
Until the method is in place, the fix is the file. mcp-remote keeps its cache under
~/.mcp-auth, or wherever MCP_REMOTE_CONFIG_DIR points, and its README says to delete it
and restart the client. Claude Code offers Clear authentication in the /mcp menu and
claude mcp logout <name> from a shell.
The connection fails with Incompatible auth server: does not support dynamic client registration, the SDK’s message when the metadata has no registration_endpoint. The fix
is a client ID registered by hand: --client-id with --callback-port in Claude Code,
--static-oauth-client-info for mcp-remote, an auth object with CLIENT_ID in Cursor’s
mcp.json, and oauth.clientId in VS Code’s.
When not to do this
Do not run this flow for a stdio server. The specification says implementations on stdio should not follow it and should take credentials from the environment, and a child process has no browser window to open.
Do not paste a token into headers for a server that offers OAuth to the person using the client. The pasted token names no particular person. Every copy of the config carries it, so one revocation ends every copy. Reserve it for a job with no person behind it.
Do not keep the in-process token check from the sample. A real resource server verifies a signed token or asks the authorization server through RFC 7662 introspection, and checks the audience either way.
Do not put the access token anywhere but the Authorization header. The specification
forbids the query string, and a token in a URL ends up in access logs.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @modelcontextprotocol/sdk 1.32.1, and zod
4.6.7. Every output block is what the command preceding it printed. Both servers ran in one
process on 127.0.0.1 and the browser was fetch. Claude Code, Cursor, VS Code, and
mcp-remote were not run; their behavior is read from their documentation, and the
specification text is the 2026-07-28 revision.
Footnotes
-
The 2026-07-28 revision deprecates dynamic client registration in favor of Client ID Metadata Documents, and keeps RFC 7591 for authorization servers that have not caught up. The draft it defers to, the Client ID Metadata Document draft, is cited at revision 00; the Datatracker holds revision 02, published July 6, 2026. Every transcript on this page uses the deprecated mechanism, which is the ordinary condition of a protocol that moves faster than its references. ↩︎ Back to text
-
RFC 8252 is a best current practice from October 2017. Section 7.3 requires the authorization server to allow any port on a loopback redirect URI, because a native client takes whatever port the operating system has free. Section 8.3 adds that
localhostis not recommended where the IP literal will do. Claude Code’s documentation records a release that sent127.0.0.1, as the RFC prefers, servers refusing it for not matching a registeredlocalhost, and the release two numbers later restoringlocalhost. The registered string decided. ↩︎ Back to text -
The authorization page lists OAuth 2.1 as
draft-ietf-oauth-v2-1-13, published May 28, 2025 and expired that November by its own header. The Datatracker’s current revision is 16, published September 3, 2026, still a draft. The error codes in every transcript on this page,invalid_client,invalid_grant, andinvalid_token, come from RFC 6749 and RFC 6750, both unchanged since 2012. ↩︎ Back to text