Skip to content

Commit d58429d

Browse files
Merge pull request #849 from corbitsdev/cl-7529-prevent-callback-server-port-conflicts-across-mcp-sessions
Keep OAuth callback sessions on ephemeral ports
2 parents 48ea82c + 2e47f45 commit d58429d

2 files changed

Lines changed: 107 additions & 8 deletions

File tree

src/mcp/callback-server.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, test } from "bun:test";
2+
import type { Server } from "node:http";
23

4+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
35
import { startCallbackServer } from "./callback-server.js";
46

57
const authorize = (
@@ -70,4 +72,82 @@ describe("MCP callback server", () => {
7072
server.close();
7173
}
7274
});
75+
76+
test("binds concurrent servers to distinct loopback ports", async () => {
77+
const first = await startCallbackServer();
78+
const second = await startCallbackServer();
79+
try {
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);
88+
} finally {
89+
first.close();
90+
second.close();
91+
}
92+
});
93+
94+
test("rejects a pending waitForCode when the server is closed", async () => {
95+
const server = await startCallbackServer();
96+
const pending = server.waitForCode(new AbortController().signal).then(
97+
() => "resolved",
98+
(err: Error) => err.message,
99+
);
100+
101+
server.close();
102+
103+
expect(await pending).toContain("closed before authorization completed");
104+
await expect(server.waitForCode(new AbortController().signal)).rejects.toThrow(
105+
"closed before authorization completed",
106+
);
107+
});
108+
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 () => {
127+
await withMockedModuleDuring(
128+
import.meta.resolve("node:http"),
129+
(real: typeof import("node:http")) => ({
130+
...real,
131+
createServer: (() => {
132+
const listeners: Partial<Record<string, (err: Error) => void>> = {};
133+
const fake = {
134+
once: (event: string, cb: (err: Error) => void) => {
135+
listeners[event] = cb;
136+
return fake;
137+
},
138+
listen: () => {
139+
listeners.error?.(new Error("listen EACCES: permission denied"));
140+
},
141+
address: (): undefined => undefined,
142+
};
143+
return fake as unknown as Server;
144+
}) as typeof real.createServer,
145+
}),
146+
async () => {
147+
await expect(startCallbackServer()).rejects.toThrow(
148+
"Could not start the OAuth callback server: listen EACCES: permission denied",
149+
);
150+
},
151+
);
152+
});
73153
});

src/mcp/callback-server.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface CallbackServer {
77
redirectUrl: string;
88
expectState: (state: string) => void;
99
// Resolves once the authorization server redirects back with a code, or rejects
10-
// if the signal aborts or the server reports an error.
10+
// if the signal aborts, the server reports an error, or close() runs.
1111
waitForCode: (signal: AbortSignal) => Promise<string>;
1212
close: () => void;
1313
}
@@ -19,13 +19,13 @@ interface CallbackWaiter {
1919
}
2020

2121
const CALLBACK_PATH = "/callback";
22+
const CLOSED_ERROR = "OAuth callback server closed before authorization completed.";
2223

23-
// Start an ephemeral loopback server to receive the OAuth redirect. `serverName`
24-
// only names the authorization on the page the browser lands on.
25-
// Binds to a
26-
// random port on 127.0.0.1 so it never collides with anything and is only
27-
// reachable locally.
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.
2827
export async function startCallbackServer(serverName?: string): Promise<CallbackServer> {
28+
let closed = false;
2929
let expectedState: string | undefined;
3030
let pendingResult: CallbackResult | undefined;
3131
let waiter: CallbackWaiter | 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;
@@ -77,7 +78,9 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
7778
});
7879

7980
await new Promise<void>((resolve, reject) => {
80-
server.once("error", reject);
81+
server.once("error", (err) => {
82+
reject(new Error(`Could not start the OAuth callback server: ${err.message}`));
83+
});
8184
server.listen(0, "127.0.0.1", resolve);
8285
});
8386

@@ -92,6 +95,10 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
9295
},
9396
waitForCode: (signal: AbortSignal) =>
9497
new Promise<string>((resolve, reject) => {
98+
if (closed) {
99+
reject(new Error(CLOSED_ERROR));
100+
return;
101+
}
95102
if (signal.aborted) {
96103
reject(new Error("aborted"));
97104
return;
@@ -115,6 +122,18 @@ export async function startCallbackServer(serverName?: string): Promise<Callback
115122
{ once: true },
116123
);
117124
}),
118-
close: () => server.close(),
125+
close: () => {
126+
// Rejecting the waiter here is what unblocks toolset disposal: a server
127+
// that merely stops listening would leave a pending waitForCode hung.
128+
if (closed) return;
129+
closed = true;
130+
pendingResult = undefined;
131+
if (waiter !== undefined) {
132+
const activeWaiter = waiter;
133+
clearAuthorization();
134+
activeWaiter.reject(new Error(CLOSED_ERROR));
135+
}
136+
server.close();
137+
},
119138
};
120139
}

0 commit comments

Comments
 (0)