Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/list-changed-callback-errors.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2636,7 +2636,15 @@ export class Client extends Protocol<ClientContext> {
}

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) {
Expand Down
80 changes: 80 additions & 0 deletions test/integration/test/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading