fix(mcp): close remaining connections when the OAuth callback server closes

This commit is contained in:
triagent 2026-10-29 22:38:18 +00:00
parent f09d01b85e
commit cdaecfc18d
2 changed files with 26 additions and 1 deletions

View file

@ -107,9 +107,13 @@ export class OAuthCallbackServer {
pending.reject(new Error("OAuth callback server closed"));
}
this.pending.clear();
await new Promise<void>((resolve, reject) => {
const closed = new Promise<void>((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 {

View file

@ -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"] });