Repository navigation
v3.1.0
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
clientMetadataUrloption enables them. *.localhostservers.http://my-app.localhost:3000/mcpis treated as loopback, likelocalhost.- 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/candhttps://example.com:443/care different clients. - Invalid URLs throw
TypeErrorimmediately. The URL must behttps: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. Ifauth.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 itssignalto cancel it.
Stricter option types
BrowserAuthOptions now describes the two ways to identify your app as separate shapes:
- Dynamic registration:
clientName, optionally withclientMetadataUrl. - 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
-
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
-
If you wrap
BrowserAuthOptionswithOmit, make it distributive. Because the type is now a union, a plainOmitmerges 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