diff --git a/packages/mcp/src/oauth/callback.ts b/packages/mcp/src/oauth/callback.ts index d087c4340..892c92f39 100644 --- a/packages/mcp/src/oauth/callback.ts +++ b/packages/mcp/src/oauth/callback.ts @@ -107,9 +107,13 @@ export class OAuthCallbackServer { pending.reject(new Error("OAuth callback server closed")); } this.pending.clear(); - await new Promise((resolve, reject) => { + const closed = new Promise((resolve, reject) => { this.server.close((error) => (error ? reject(error) : resolve())); }); + // `close()` waits for every connection, and a browser may keep an unused one open (a preconnect or a + // spare keep-alive socket) for minutes. Responses have been sent by now, so drop them. + this.server.closeAllConnections(); + await closed; } private reply(response: ServerResponse, status: number, page: OAuthCallbackPage): void { diff --git a/packages/mcp/test/oauth.test.ts b/packages/mcp/test/oauth.test.ts index c6d175b8c..083204424 100644 --- a/packages/mcp/test/oauth.test.ts +++ b/packages/mcp/test/oauth.test.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { createConnection } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { McpClient, StreamableHttpTransport } from "../src/index.ts"; import { @@ -622,6 +624,25 @@ describe("OAuthCallbackServer pages", () => { } }); + // #11311 + it("closes while the browser keeps an unused connection open", async () => { + const callback = await OAuthCallbackServer.listen(); + const url = new URL(callback.redirectUrl); + const spare = createConnection({ host: url.hostname, port: Number(url.port) }); + try { + await once(spare, "connect"); + const pending = callback.waitForCallback("s1"); + const response = await fetch(`${callback.redirectUrl}?code=abc&state=s1`); + expect(await response.text()).toBe("Authorization complete. You may close this window."); + expect((await pending).code).toBe("abc"); + const closed = callback.close().then(() => "closed"); + const timeout = new Promise((resolve) => setTimeout(() => resolve("timed out"), 2000)); + expect(await Promise.race([closed, timeout])).toBe("closed"); + } finally { + spare.destroy(); + } + }); + // #10302 it("rejects a response on another path than the expected one", async () => { const callback = await OAuthCallbackServer.listen({ extraPaths: ["/callback/server-id"] });