Merge pull request #2236 from modelcontextprotocol/v2/fix/2221-oauth-refresh-unhandled-rejection

fix: terminate the OAuth connection-details refresh promise chain
This commit is contained in:
Cliff Hall 2026-09-04 08:52:31 -04:00 • committed by GitHub
commit b4b750ff48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 105 additions and 6 deletions

View file

@ -505,6 +505,87 @@ describe("useOAuthRecovery", () => {
expect(h.api().connectionInfoOAuth).toBeUndefined();
});
it("clears already-loaded details when a refresh read rejects", async () => {
let read: () => Promise<unknown> = () =>
Promise.resolve({ tokens: { access_token: "t" } });
const client = fakeClient({ getOAuthState: vi.fn(() => read()) });
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined());
// The panel reports the *current* state, so a failed read must clear the
// details it already holds rather than leave a stale answer on screen.
read = () => Promise.reject(new Error("backend down"));
await act(async () => {
client.emit("oauthComplete", {});
});
await waitFor(() => expect(h.api().connectionInfoOAuth).toBeUndefined());
});
it("ignores an earlier read that rejects after a newer one succeeded", async () => {
let failFirst: (reason: unknown) => void = () => {};
let call = 0;
const client = fakeClient({
getOAuthState: vi.fn((): Promise<unknown> => {
call += 1;
if (call === 1) {
return new Promise((_resolve, reject) => {
failFirst = reject;
});
}
return Promise.resolve({ tokens: { access_token: "t" } });
}),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await waitFor(() =>
expect(client.getOAuthState).toHaveBeenCalledTimes(1),
);
await act(async () => {
client.emit("oauthComplete", {});
});
await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined());
// The stale read settles last. Without the sequence guard its catch
// would clear the newer read's result.
await act(async () => {
failFirst(new Error("backend down"));
});
expect(h.api().connectionInfoOAuth).toBeDefined();
});
it("drops a rejected read that lands after the client was replaced", async () => {
let failStale: (reason: unknown) => void = () => {};
const stale = fakeClient({
getOAuthState: vi.fn(
() =>
new Promise((_resolve, reject) => {
failStale = reject;
}),
),
});
const props: HarnessProps = {
servers: [entry("a")],
activeServerId: "a",
client: stale,
};
const h = harness(props);
await waitFor(() => expect(stale.getOAuthState).toHaveBeenCalled());
// Reconnect. The new client's details are what the panel must keep.
const fresh = fakeClient({
getOAuthState: vi
.fn()
.mockResolvedValue({ tokens: { access_token: "t" } }),
});
h.rerender({ ...props, client: fresh });
await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined());
await act(async () => {
failStale(new Error("backend down"));
});
expect(h.api().connectionInfoOAuth).toBeDefined();
});
it("drops a state read that lands after the session ended", async () => {
let settle: (value: unknown) => void = () => {};
const client = fakeClient({

View file

@ -440,13 +440,31 @@ export function useOAuthRecovery({
}
let cancelled = false;
// Reads are concurrent — an `oauthComplete` can start a second one while
// the first is still in flight — and nothing makes them settle in order.
// Only the newest read may write, so a slow earlier one cannot overwrite
// (or, on rejection, clear) a newer result.
let latest = 0;
const refresh = (): void => {
void inspectorClient.getOAuthState().then((state) => {
if (cancelled) return;
setConnectionInfoOAuthWhenConnected(
state ? oauthDetailsFromConnectionState(state) : undefined,
);
});
const seq = ++latest;
// void: a synchronous useEffect body cannot await. The chain is
// terminated below, so the rejection is handled rather than discarded.
void inspectorClient
.getOAuthState()
.then((state) => {
if (cancelled || seq !== latest) return;
setConnectionInfoOAuthWhenConnected(
state ? oauthDetailsFromConnectionState(state) : undefined,
);
})
.catch(() => {
// The read failed (backend down, 401 on the API token, malformed
// stored state). Clear rather than keep the last successful read —
// a stale answer is indistinguishable from a fresh one in the panel.
if (cancelled || seq !== latest) return;
setConnectionInfoOAuthWhenConnected(undefined);
});
};
const onAmbientAuthChallenge = (): void => {