How-to › Use AI to do the integration

How to authorize a remote MCP server with OAuth from a client#

Walk an MCP client through a protected server's OAuth flow, keep its registration and tokens where you can find them, and recover when the server forgets it.

Audience
Agent builder
Level
advanced
Topic
Connect MCP clients to servers
Verified

Read first: Connect a remote MCP server to Claude Desktop

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A static bearer token in client headersA server that issues personal access tokens, a job with nobody at a keyboard, or a server with no authorization server behind itOne secret in every copy of the config, no refresh, no per-user identity, and a revocation that stops every copy at onceA person uses the client and the server advertises an authorization server
MCP OAuth 2.1 with dynamic client registrationA server that advertises an authorization server, and a person who can sign in onceA loopback listener, a browser step, and a cache of registration and tokens that outlives the server’s memory of themThe authorization server registers nothing on demand and hands out client IDs by hand
mcp-remote holding the credential locallyA client that speaks only stdio, or one whose own OAuth support cannot reach the serverA 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 fileThe 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.

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

  1. 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

  2. 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 localhost is not recommended where the IP literal will do. Claude Code’s documentation records a release that sent 127.0.0.1, as the RFC prefers, servers refusing it for not matching a registered localhost, and the release two numbers later restoring localhost. The registered string decided. ↩︎ Back to text

  3. 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, and invalid_token, come from RFC 6749 and RFC 6750, both unchanged since 2012. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.