Skip to content

Commit 2e47f45

Browse files
committed
Fail closed when the OAuth callback server is disposed
close() must reject waitForCode so toolset disposal cannot leave interactive authorization hung. Bind failures keep the OS error rather than telling the user to retry an ephemeral port.
1 parent 857b119 commit 2e47f45

2 files changed

Lines changed: 40 additions & 27 deletions

File tree

src/mcp/callback-server.test.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,21 @@ describe("MCP callback server", () => {
7373
}
7474
});
7575

76-
test("binds an ephemeral OS-assigned port on 127.0.0.1", async () => {
77-
const server = await startCallbackServer();
76+
test("binds concurrent servers to distinct loopback ports", async () => {
77+
const first = await startCallbackServer();
78+
const second = await startCallbackServer();
7879
try {
79-
const url = new URL(server.redirectUrl);
80-
81-
expect(url.hostname).toBe("127.0.0.1");
82-
expect(Number(url.port)).toBeGreaterThan(0);
80+
const firstUrl = new URL(first.redirectUrl);
81+
const secondUrl = new URL(second.redirectUrl);
82+
83+
expect(firstUrl.hostname).toBe("127.0.0.1");
84+
expect(secondUrl.hostname).toBe("127.0.0.1");
85+
expect(firstUrl.port).not.toBe(secondUrl.port);
86+
expect(Number(firstUrl.port)).toBeGreaterThan(0);
87+
expect(Number(secondUrl.port)).toBeGreaterThan(0);
8388
} finally {
84-
server.close();
89+
first.close();
90+
second.close();
8591
}
8692
});
8793

@@ -100,7 +106,24 @@ describe("MCP callback server", () => {
100106
);
101107
});
102108

103-
test("reports a clear actionable error when the server fails to bind", async () => {
109+
test("close is idempotent and does not leak a waiter after a late callback", async () => {
110+
const server = await startCallbackServer();
111+
authorize(server, "expected");
112+
server.close();
113+
server.close();
114+
115+
await expect(
116+
fetch(`${server.redirectUrl}?code=abc&state=expected`).then(
117+
() => "fetched",
118+
(err: unknown) => err,
119+
),
120+
).resolves.toBeInstanceOf(Error);
121+
await expect(server.waitForCode(new AbortController().signal)).rejects.toThrow(
122+
"closed before authorization completed",
123+
);
124+
});
125+
126+
test("rejects start when listen fails without rewriting the OS error as a retry", async () => {
104127
await withMockedModuleDuring(
105128
import.meta.resolve("node:http"),
106129
(real: typeof import("node:http")) => ({
@@ -113,23 +136,17 @@ describe("MCP callback server", () => {
113136
return fake;
114137
},
115138
listen: () => {
116-
listeners.error?.(new Error("listen EADDRINUSE: address already in use"));
139+
listeners.error?.(new Error("listen EACCES: permission denied"));
117140
},
118141
address: (): undefined => undefined,
119142
};
120143
return fake as unknown as Server;
121144
}) as typeof real.createServer,
122145
}),
123146
async () => {
124-
const err: unknown = await startCallbackServer().then(
125-
() => undefined,
126-
(failure: unknown) => failure,
147+
await expect(startCallbackServer()).rejects.toThrow(
148+
"Could not start the OAuth callback server: listen EACCES: permission denied",
127149
);
128-
const error = err as Error;
129-
130-
expect(error.message).toContain("OAuth callback server");
131-
expect(error.message).toContain("EADDRINUSE");
132-
expect(error.message).toContain("retry");
133150
},
134151
);
135152
});

src/mcp/callback-server.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ interface CallbackWaiter {
2121
const CALLBACK_PATH = "/callback";
2222
const CLOSED_ERROR = "OAuth callback server closed before authorization completed.";
2323

24-
// The listen port is always 0, so the OS assigns an ephemeral port and
25-
// concurrent sessions can never collide on a fixed callback port. `serverName`
26-
// only names the authorization on the page the browser lands on.
24+
// Start a loopback server to receive the OAuth redirect. close() fail-closes
25+
// waitForCode so disposing the toolset cannot leave authorization hung.
26+
// `serverName` only names the authorization on the page the browser lands on.
2727
export async function startCallbackServer(serverName?: string): Promise<CallbackServer> {
2828
let closed = false;
2929
let expectedState: string | undefined;
@@ -36,6 +36,7 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
3636
};
3737

3838
const deliver = (result: CallbackResult): void => {
39+
if (closed) return;
3940
if (waiter === undefined) {
4041
pendingResult = result;
4142
return;
@@ -78,13 +79,7 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
7879

7980
await new Promise<void>((resolve, reject) => {
8081
server.once("error", (err) => {
81-
reject(
82-
new Error(
83-
`Could not start the OAuth callback server: ${err.message}. ` +
84-
"The server always requests an ephemeral port, so this is unexpected — " +
85-
"retry connecting to the MCP server.",
86-
),
87-
);
82+
reject(new Error(`Could not start the OAuth callback server: ${err.message}`));
8883
});
8984
server.listen(0, "127.0.0.1", resolve);
9085
});
@@ -130,6 +125,7 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
130125
close: () => {
131126
// Rejecting the waiter here is what unblocks toolset disposal: a server
132127
// that merely stops listening would leave a pending waitForCode hung.
128+
if (closed) return;
133129
closed = true;
134130
pendingResult = undefined;
135131
if (waiter !== undefined) {

0 commit comments

Comments
 (0)