Repository navigation
Description
What happened?
When an onChanged callback configured through ClientOptions.listChanged throws, the error is not reported on client.onerror. It becomes an unhandled promise rejection, which ends a Node process with exit code 1.
With autoRefresh on (the default) there is a second problem. The callback runs inside the try that guards the list fetch, so its own exception is caught there and the callback is called again as onChanged(thatError, null), as if the refresh had failed. The docs example logs that argument as Refresh failed:.
Output of the script below on main (919bb8e), built packages, Node 24.18 (paths and stack traces shortened):
$ node repro.mjs
onChanged called with null [ 't' ]
onChanged called with bug in the application callback null
.../repro.mjs:22
throw new Error('bug in the application callback');
^
Error: bug in the application callback
at onChanged (.../repro.mjs:22:27)
at refresh (.../packages/client/dist/public-B_Q_nc-B.mjs:4029:5)
exit code: 1
$ node repro.mjs manual
onChanged called with null null
.../repro.mjs:22
throw new Error('bug in the application callback');
^
Error: bug in the application callback
at onChanged (.../repro.mjs:22:27)
at refresh (.../packages/client/dist/public-B_Q_nc-B.mjs:4023:5)
exit code: 1
The cause is in _setupListChangedHandler (packages/client/src/client/client.ts). refresh() is started from a setTimeout when debounced and called without being returned otherwise, so its rejection never reaches the catch in Protocol._onnotification that reports other notification handler errors.
This comes from reading the code and running the script. It is not from a production incident.
What did you expect?
The callback runs once per notification, and an error it throws is reported on client.onerror, the way Uncaught error in notification handler: ... is for other handlers.
Code to reproduce
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { Server } from '@modelcontextprotocol/server';
const autoRefresh = process.argv[2] !== 'manual';
const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: { listChanged: true } } });
server.setRequestHandler('tools/list', async () => ({ tools: [{ name: 't', inputSchema: { type: 'object' } }] }));
const client = new Client(
{ name: 'c', version: '1' },
{
listChanged: {
tools: {
autoRefresh,
debounceMs: 0,
onChanged: (error, tools) => {
console.log('onChanged called with', error?.message ?? null, tools?.map(tool => tool.name) ?? null);
throw new Error('bug in the application callback');
}
}
}
}
);
client.onerror = error => console.log('client.onerror:', error.message);
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, 200));
await client.close();
console.log('reached the end of the script');SDK version
2.4.0 (main at 919bb8e). The same code is on v1.x in src/client/index.ts.
Area
Client
Additional context
I have a small fix with two regression tests and a changeset, and will open a PR that references this issue.
AI disclosure: this report and the fix were written primarily by Claude Code, an AI coding assistant, working at my direction on my machine. The output above is from a local run there.
Activity