From aa47d066dba22b9d1e5cd83df54049cb28406508 Mon Sep 17 00:00:00 2001 From: Minglong Pan <56749246+minglong51@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:51:35 -0700 Subject: [PATCH] fix(core): reset timeout on progress without handler --- .../fix-reset-timeout-without-onprogress.md | 6 +++ packages/core-internal/src/shared/protocol.ts | 12 +++-- .../test/shared/protocol.test.ts | 49 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-reset-timeout-without-onprogress.md diff --git a/.changeset/fix-reset-timeout-without-onprogress.md b/.changeset/fix-reset-timeout-without-onprogress.md new file mode 100644 index 0000000000..2a033a81a6 --- /dev/null +++ b/.changeset/fix-reset-timeout-without-onprogress.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Fix `resetTimeoutOnProgress` so it works without an `onprogress` handler. Requests that opt into timeout resets now advertise a progress token, reset their timeout when progress arrives, and no longer report progress for the known in-flight request as an unknown-token error. diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 0a19770082..635d8f37b5 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1164,14 +1164,14 @@ export abstract class Protocol { const messageId = Number(progressToken); const handler = this._progressHandlers.get(messageId); - if (!handler) { + const responseHandler = this._responseHandlers.get(messageId); + const timeoutInfo = this._timeoutInfo.get(messageId); + + if (!handler && !responseHandler) { this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); return; } - const responseHandler = this._responseHandlers.get(messageId); - const timeoutInfo = this._timeoutInfo.get(messageId); - if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { try { this._resetTimeout(messageId); @@ -1185,7 +1185,7 @@ export abstract class Protocol { } } - handler(params); + handler?.(params); } /** @@ -1427,6 +1427,8 @@ export abstract class Protocol { if (options?.onprogress) { this._progressHandlers.set(messageId, options.onprogress); + } + if (options?.onprogress || options?.resetTimeoutOnProgress) { jsonrpcRequest.params = { ...request.params, _meta: { diff --git a/packages/core-internal/test/shared/protocol.test.ts b/packages/core-internal/test/shared/protocol.test.ts index 2ecdc40adc..f5ae1d6730 100644 --- a/packages/core-internal/test/shared/protocol.test.ts +++ b/packages/core-internal/test/shared/protocol.test.ts @@ -444,6 +444,55 @@ describe('protocol tests', () => { await expect(requestPromise).resolves.toEqual({ result: 'success' }); }); + test('should reset timeout without an onprogress handler', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onErrorMock = vi.fn(); + protocol.onerror = onErrorMock; + + const requestPromise = protocol.request(request, mockSchema, { + timeout: 1000, + resetTimeoutOnProgress: true + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + _meta: { + progressToken: 0 + } + } + }), + expect.any(Object) + ); + + vi.advanceTimersByTime(800); + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 50, + total: 100 + } + }); + await Promise.resolve(); + + expect(onErrorMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: { result: 'success' } + }); + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: 'success' }); + }); + test('should respect maxTotalTimeout', async () => { await protocol.connect(transport); const request = { method: 'example', params: {} };