Skip to content

v3.1.0

Choose a tag to compare

@koistya koistya released this 01 Oct 22:05
5bddbcc

oauth-callback 3.1 brings oauth-callback/mcp up to date with the current MCP authorization spec. Clients can identify themselves with a hosted metadata document instead of registering on every server. Local *.localhost servers work over plain http:, and a sign-in you give up on no longer holds the redirect port until the timeout.

It's a minor release: existing code keeps working. The one new requirement is @modelcontextprotocol/client 2.2+ (see Upgrading).

Highlights

  • Client ID Metadata Documents (CIMD). The MCP 2026-07-28 spec prefers them over Dynamic Client Registration. A new clientMetadataUrl option enables them.
  • *.localhost servers. http://my-app.localhost:3000/mcp is treated as loopback, like localhost.
  • Abandoned sign-ins are cleaned up. Closing the client ends a sign-in it left waiting and frees the redirect port.
  • Option mistakes caught at compile time. TypeScript now rejects contradictory client options.

Client ID Metadata Documents

Host a JSON metadata document for your app at an HTTPS URL, and pass that URL as clientMetadataUrl. Keep clientName too: it's the fallback for authorization servers that don't support CIMD yet.

import { browserAuth } from "oauth-callback/mcp";

const auth = browserAuth({
  serverUrl: "https://mcp.example.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientMetadataUrl: "https://acme.example.com/oauth/client.json", // used where supported
  clientName: "Acme CLI", // Dynamic Client Registration otherwise
});

The document's client_id must be that same URL, and its redirect_uris must include redirectUri. Serve it with 200 OK directly, because authorization servers don't follow redirects.

How the client is chosen:

  • The MCP SDK decides, in this order: a client already in the store, then CIMD if the authorization server supports it, then Dynamic Client Registration.
  • A stored client stays in use. It isn't swapped for CIMD automatically. To switch, sign out with await auth.invalidateCredentials("all").
  • The URL is used exactly as written. CIMD compares client IDs as plain strings, so https://example.com/c and https://example.com:443/c are different clients.
  • Invalid URLs throw TypeError immediately. The URL must be https: with a non-root path, with no userinfo, fragment, or ./.. segments.

*.localhost servers

Plain http: was already allowed for localhost, 127.0.0.1 and [::1]. It's now also allowed for *.localhost hosts (RFC 6761), in serverUrl and in authorization URLs:

browserAuth({
  serverUrl: "http://tenant.localhost:3000/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientName: "Acme CLI",
});

Redirect URIs still accept only 127.0.0.1, [::1] and localhost. The library has to bind the redirect host, and an OS isn't required to resolve *.localhost names.

Closing a client ends its pending sign-in

When a server asks for more scope mid-session (a step-up), the SDK starts a new sign-in and the request fails with UnauthorizedError. If you decide not to complete it, close the client:

try {
  await client.callTool(request);
} catch (error) {
  if (!(error instanceof UnauthorizedError)) throw error;
  await client.close(); // ends the pending sign-in and frees the redirect port
}

Before, that sign-in kept its callback listener open until timeout (5 minutes by default), so the next run could hit an occupied port. Now:

  • Closing frees the port at once. Stored credentials are kept; this isn't a sign-out.
  • A waiting connect() rejects. If auth.connect(client) is already completing that sign-in, it rejects instead of waiting, and doesn't reconnect the closed client.
  • A connect() that hasn't started completing the sign-in still reconnects. Abort its signal to cancel it.

Stricter option types

BrowserAuthOptions now describes the two ways to identify your app as separate shapes:

  • Dynamic registration: clientName, optionally with clientMetadataUrl.
  • Pre-registered client: clientInformation.

Mixing them was always rejected at runtime. Now TypeScript catches it too:

browserAuth({
  serverUrl,
  redirectUri,
  clientInformation: { client_id: "…", issuer: "https://auth.example.com" },
  clientName: "Acme CLI", // ✗ type error: a pre-registered client takes no clientName
});

Upgrading

  1. Update the MCP SDK to 2.2 or newer. The peer dependency is now @modelcontextprotocol/client ^2.2. SDK 2.1 rejects plain-http: token endpoints on *.localhost.

    npm install oauth-callback@3.1 @modelcontextprotocol/client@^2.2
  2. If you wrap BrowserAuthOptions with Omit, make it distributive. Because the type is now a union, a plain Omit merges both shapes and no longer type-checks when passed back:

    type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
      ? Omit<T, K>
      : never;
    
    type MyAuthOptions = DistributiveOmit<BrowserAuthOptions, "serverUrl">;

Nothing else changes: getAuthCode(), stores and stored credentials work as before.

Documentation

  • browserAuth API: new options and behavior.
  • ADR-006: why the SDK picks the client, and why a sign-in ends with its connection.

Full changelog: v3.0.0...v3.1.0