From 61699e66a49dec82d3f79a231195e514c50581da Mon Sep 17 00:00:00 2001 From: YusefSyed <211442445+YusefSyed@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:54:51 -0500 Subject: [PATCH] fix(client): report a throwing listChanged callback on onerror An error thrown by a listChanged onChanged callback escaped as an unhandled promise rejection. With autoRefresh the callback was also called a second time, with its own error passed as a failed refresh. Catch the error where the callback is invoked and report it on Client.onerror, so the callback runs once per notification. --- .changeset/list-changed-callback-errors.md | 5 ++ packages/client/src/client/client.ts | 10 ++- test/integration/test/client/client.test.ts | 80 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 .changeset/list-changed-callback-errors.md diff --git a/.changeset/list-changed-callback-errors.md b/.changeset/list-changed-callback-errors.md new file mode 100644 index 0000000000..efdb42f4eb --- /dev/null +++ b/.changeset/list-changed-callback-errors.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Report an error thrown by a `listChanged` `onChanged` callback on `Client.onerror`. Previously the throw became an unhandled promise rejection, and with `autoRefresh` the callback was first called a second time with its own error as if the list refresh had failed. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index f071b13e50..32e4ab908b 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2636,7 +2636,15 @@ export class Client extends Protocol { } const { autoRefresh, debounceMs } = parseResult.data; - const { onChanged } = options; + + // A throwing callback is reported on `onerror`; it must not be called again with its own error as a failed refresh. + const onChanged = (error: Error | null, items: T[] | null): void => { + try { + options.onChanged(error, items); + } catch (callbackError) { + this.onerror?.(new Error(`Uncaught error in ${listType} listChanged callback: ${callbackError}`)); + } + }; const refresh = async () => { if (!autoRefresh) { diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index bc5d19b8cf..0d30a39326 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1778,6 +1778,86 @@ test('should handle partial listChanged capability support', async () => { expect(promptNotifications).toHaveLength(0); }); +/*** + * Test: A throwing listChanged callback is reported on onerror, once + */ +test('should report a throwing listChanged callback on onerror without calling it again', async () => { + const notifications: [Error | null, Tool[] | null][] = []; + const errors: Error[] = []; + + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + + server.setRequestHandler('tools/list', async () => ({ + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + })); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => { + notifications.push([err, tools]); + throw new Error('callback failed'); + } + } + } + } + ); + client.onerror = error => errors.push(error); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await server.notification({ method: 'notifications/tools/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // The refresh succeeded, so the callback sees the tools once and never its own error + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(1); + expect(errors.map(error => error.message)).toEqual(['Uncaught error in tools listChanged callback: Error: callback failed']); +}); + +/*** + * Test: A throwing listChanged callback is reported on onerror with manual refresh and debounce + */ +test('should report a throwing listChanged callback on onerror with manual refresh', async () => { + const notifications: [Error | null, Tool[] | null][] = []; + const errors: Error[] = []; + + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 10, + onChanged: (err, tools) => { + notifications.push([err, tools]); + throw new Error('callback failed'); + } + } + } + } + ); + client.onerror = error => errors.push(error); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await server.notification({ method: 'notifications/tools/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(notifications).toEqual([[null, null]]); + expect(errors.map(error => error.message)).toEqual(['Uncaught error in tools listChanged callback: Error: callback failed']); +}); + describe('outputSchema validation', () => { /*** * Test: Validate structuredContent Against outputSchema