Skip to content

Commit c0cd01a

Browse files
claude[bot]felixweinbergerclaude
authored
fix(client): retry the SSE connection once after onUnauthorized (#2905)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent aca8ba3 commit c0cd01a

3 files changed

Lines changed: 92 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
`SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh.

‎packages/client/src/client/sse.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ export class SSEClientTransport implements Transport {
175175
}
176176

177177
private _last401Response?: Response;
178+
// True between a 401-triggered reconnect and the next successful open.
179+
private _connectAuthRetried = false;
178180

179181
private async _commonHeaders(): Promise<Headers> {
180182
// Start from the caller-supplied `requestInit.headers` and `set()` the
@@ -239,9 +241,10 @@ export class SSEClientTransport implements Transport {
239241

240242
this._eventSource.onerror = event => {
241243
if (event.code === 401 && this._authProvider) {
242-
if (this._authProvider.onUnauthorized && this._last401Response) {
244+
if (this._authProvider.onUnauthorized && this._last401Response && !this._connectAuthRetried) {
243245
const response = this._last401Response;
244246
this._last401Response = undefined;
247+
this._connectAuthRetried = true;
245248
this._eventSource?.close();
246249
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
247250
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
@@ -250,14 +253,25 @@ export class SSEClientTransport implements Transport {
250253
// stamp: covers the SDK's OAuth flow and custom
251254
// callbacks alike.
252255
(error: unknown) => {
256+
this._connectAuthRetried = false;
253257
markAuthSeamEscape(error);
254258
this.onerror?.(error as Error);
255259
reject(error);
256260
}
257261
);
258262
return;
259263
}
260-
const error = markAuthSeamEscape(new UnauthorizedError());
264+
const retried = this._connectAuthRetried;
265+
this._connectAuthRetried = false;
266+
const error = markAuthSeamEscape(
267+
retried
268+
? new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
269+
status: 401,
270+
statusText: this._last401Response?.statusText ?? ''
271+
})
272+
: new UnauthorizedError()
273+
);
274+
this._last401Response = undefined;
261275
reject(error);
262276
this.onerror?.(error);
263277
return;
@@ -270,6 +284,7 @@ export class SSEClientTransport implements Transport {
270284

271285
this._eventSource.onopen = () => {
272286
// The connection is open, but we need to wait for the endpoint to be received.
287+
this._connectAuthRetried = false;
273288
};
274289

275290
this._eventSource.addEventListener('endpoint', (event: Event) => {

‎packages/client/test/client/sse.test.ts‎

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,10 +1783,43 @@ describe('SSEClientTransport', () => {
17831783
await expect(transport.finishAuth('auth-code')).rejects.toThrow('finishAuth requires an OAuthClientProvider');
17841784
});
17851785

1786-
it('SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt', async () => {
1786+
it('SSE connect: a second 401 right after a refresh rejects, onUnauthorized called once', async () => {
1787+
await resourceServer.close();
1788+
1789+
let getAttempt = 0;
1790+
resourceServer = createServer((req, res) => {
1791+
if (req.method === 'GET') {
1792+
getAttempt++;
1793+
res.writeHead(401).end(); // always 401
1794+
}
1795+
});
1796+
resourceBaseUrl = await listenOnRandomPort(resourceServer);
1797+
1798+
// Backstop so an unbounded retry ends the test with a clear failure instead of a hang.
1799+
let calls = 0;
1800+
const authProvider: AuthProvider = {
1801+
token: vi.fn(async () => 'still-bad'),
1802+
onUnauthorized: vi.fn(async () => {
1803+
if (++calls >= 10) throw new Error('backstop: onUnauthorized called 10 times');
1804+
})
1805+
};
1806+
transport = new SSEClientTransport(resourceBaseUrl, { authProvider });
1807+
const onerror = vi.fn();
1808+
transport.onerror = onerror;
1809+
1810+
const error = await transport.start().catch(e => e);
1811+
expect(error).toBeInstanceOf(SdkHttpError);
1812+
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
1813+
expect((error as SdkHttpError).status).toBe(401);
1814+
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
1815+
expect(getAttempt).toBe(2);
1816+
expect(onerror).toHaveBeenCalledTimes(1);
1817+
});
1818+
1819+
it('SSE connect 401 retry does not poison future 401s — a 401 on a later reconnect refreshes again', async () => {
17871820
// Regression: _startOrAuth(true) baked isAuthRetry=true into the retry EventSource's
17881821
// onerror closure, so a subsequent 401 (token expiry on reconnect) would throw
1789-
// instead of refreshing. Fix: retry always calls _startOrAuth() fresh.
1822+
// instead of refreshing. The retry guard resets once the stream opens.
17901823
await resourceServer.close();
17911824

17921825
let getAttempt = 0;
@@ -1796,7 +1829,8 @@ describe('SSEClientTransport', () => {
17961829
return;
17971830
}
17981831
getAttempt++;
1799-
if (getAttempt < 3) {
1832+
// 1: 401, 2: opens then drops, 3: 401 on the automatic reconnect, 4: opens and stays.
1833+
if (getAttempt === 1 || getAttempt === 3) {
18001834
res.writeHead(401).end();
18011835
return;
18021836
}
@@ -1805,8 +1839,12 @@ describe('SSEClientTransport', () => {
18051839
'Cache-Control': 'no-cache, no-transform',
18061840
Connection: 'keep-alive'
18071841
});
1842+
res.write('retry: 10\n');
18081843
res.write('event: endpoint\n');
18091844
res.write(`data: ${resourceBaseUrl.href}post\n\n`);
1845+
if (getAttempt === 2) {
1846+
res.end();
1847+
}
18101848
});
18111849
resourceBaseUrl = await listenOnRandomPort(resourceServer);
18121850

@@ -1816,17 +1854,17 @@ describe('SSEClientTransport', () => {
18161854
};
18171855
transport = new SSEClientTransport(resourceBaseUrl, { authProvider });
18181856

1819-
await transport.start(); // should resolve on attempt 3
1857+
await transport.start(); // resolves on attempt 2
1858+
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
18201859

1860+
await vi.waitFor(() => expect(getAttempt).toBe(4), { timeout: 3000 });
18211861
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2);
1822-
expect(getAttempt).toBe(3);
18231862
});
18241863

18251864
it('retry failure during SSE connect fires onerror exactly once', async () => {
18261865
// Regression: when the retry EventSource rejected, its onerror fired inside, then
18271866
// the outer .then() rejection handler fired onerror AGAIN for the same error.
18281867
// Fix: inner retry chains to .then(resolve, reject) — no outer onerror call.
1829-
// onUnauthorized's own failure is handled separately and fires onerror once.
18301868
await resourceServer.close();
18311869

18321870
resourceServer = createServer((req, res) => {
@@ -1836,20 +1874,40 @@ describe('SSEClientTransport', () => {
18361874
});
18371875
resourceBaseUrl = await listenOnRandomPort(resourceServer);
18381876

1839-
const onUnauthorized: AuthProvider['onUnauthorized'] = vi
1840-
.fn()
1841-
.mockResolvedValueOnce(undefined) // first call succeeds → triggers retry
1842-
.mockRejectedValueOnce(new Error('refresh failed')); // second call (in retry) throws
18431877
const authProvider: AuthProvider = {
18441878
token: vi.fn(async () => 'token'),
1845-
onUnauthorized
1879+
onUnauthorized: vi.fn(async () => {})
1880+
};
1881+
transport = new SSEClientTransport(resourceBaseUrl, { authProvider });
1882+
const onerror = vi.fn();
1883+
transport.onerror = onerror;
1884+
1885+
await expect(transport.start()).rejects.toThrow('Server returned 401 after re-authentication');
1886+
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
1887+
expect(onerror).toHaveBeenCalledTimes(1);
1888+
expect(onerror.mock.calls[0]![0].message).toBe('Server returned 401 after re-authentication');
1889+
});
1890+
1891+
it('a failing onUnauthorized during SSE connect fires onerror exactly once', async () => {
1892+
await resourceServer.close();
1893+
1894+
resourceServer = createServer((req, res) => {
1895+
if (req.method === 'GET') {
1896+
res.writeHead(401).end(); // always 401
1897+
}
1898+
});
1899+
resourceBaseUrl = await listenOnRandomPort(resourceServer);
1900+
1901+
const authProvider: AuthProvider = {
1902+
token: vi.fn(async () => 'token'),
1903+
onUnauthorized: vi.fn().mockRejectedValueOnce(new Error('refresh failed'))
18461904
};
18471905
transport = new SSEClientTransport(resourceBaseUrl, { authProvider });
18481906
const onerror = vi.fn();
18491907
transport.onerror = onerror;
18501908

18511909
await expect(transport.start()).rejects.toThrow('refresh failed');
1852-
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2);
1910+
expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1);
18531911
expect(onerror).toHaveBeenCalledTimes(1);
18541912
expect(onerror.mock.calls[0]![0].message).toBe('refresh failed');
18551913
});

0 commit comments

Comments
 (0)