Skip to content

Commit 4229df5

Browse files
committed
fix(client): paginate tools listChanged refresh
1 parent 099e11c commit 4229df5

2 files changed

Lines changed: 76 additions & 7 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,7 @@ export class Client extends Protocol<ClientContext> {
264264
private _setupListChangedHandlers(config: ListChangedHandlers): void {
265265
if (config.tools && this._serverCapabilities?.tools?.listChanged) {
266266
this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => {
267-
const result = await this.listTools();
268-
return result.tools;
267+
return this._listAllTools();
269268
});
270269
}
271270

@@ -284,6 +283,19 @@ export class Client extends Protocol<ClientContext> {
284283
}
285284
}
286285

286+
private async _listAllTools(): Promise<Tool[]> {
287+
const tools: Tool[] = [];
288+
let cursor: string | undefined;
289+
290+
do {
291+
const result = await this.listTools(cursor === undefined ? undefined : { cursor });
292+
tools.push(...result.tools);
293+
cursor = result.nextCursor;
294+
} while (cursor !== undefined);
295+
296+
return tools;
297+
}
298+
287299
/**
288300
* Registers new capabilities. This can only be called before connecting to a transport.
289301
*

test/integration/test/client/client.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,15 +1945,25 @@ describe('outputSchema validation', () => {
19451945
* or the use of other tools (SEP-2106 safety-guard isolation).
19461946
*/
19471947
test('preserves outputSchema validation metadata across paginated tool listings', async () => {
1948-
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
1948+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
1949+
const listRequests: Array<string | undefined> = [];
1950+
const listChangedNotifications: Array<[Error | null, Tool[] | null]> = [];
1951+
let resolveListChangedNotification: () => void = () => {};
1952+
const listChangedNotification = new Promise<void>(resolve => {
1953+
resolveListChangedNotification = resolve;
1954+
});
1955+
let lastPageToolReturnsInvalid = false;
1956+
let lastPageBadToolCalled = false;
19491957

19501958
server.setRequestHandler('initialize', async request => ({
19511959
protocolVersion: request.params.protocolVersion,
1952-
capabilities: { tools: {} },
1960+
capabilities: { tools: { listChanged: true } },
19531961
serverInfo: { name: 'test-server', version: '1.0.0' }
19541962
}));
19551963

19561964
server.setRequestHandler('tools/list', async request => {
1965+
listRequests.push(request.params?.cursor);
1966+
19571967
if (request.params?.cursor === 'page-2') {
19581968
return {
19591969
tools: [
@@ -1962,6 +1972,12 @@ describe('outputSchema validation', () => {
19621972
description: 'a tool on the final page',
19631973
inputSchema: { type: 'object', properties: {} },
19641974
outputSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] }
1975+
},
1976+
{
1977+
name: 'last-page-bad-tool',
1978+
description: 'a final-page tool with an outputSchema the SEP-2106 guard rejects',
1979+
inputSchema: { type: 'object', properties: {} },
1980+
outputSchema: { $ref: 'https://evil.example/final-page-schema.json' }
19651981
}
19661982
]
19671983
};
@@ -1989,23 +2005,40 @@ describe('outputSchema validation', () => {
19892005

19902006
server.setRequestHandler('tools/call', async request => {
19912007
if (request.params.name === 'last-page-tool') {
1992-
return { content: [], structuredContent: { ok: true } };
2008+
return { content: [], structuredContent: { ok: lastPageToolReturnsInvalid ? 'not-a-boolean' : true } };
2009+
}
2010+
if (request.params.name === 'last-page-bad-tool') {
2011+
lastPageBadToolCalled = true;
2012+
return { content: [], structuredContent: { irrelevant: true } };
19932013
}
19942014
if (request.params.name === 'validated-tool') {
19952015
return { content: [], structuredContent: { ok: 'not-a-boolean' } };
19962016
}
19972017
return { content: [], structuredContent: { irrelevant: true } };
19982018
});
19992019

2000-
const client = new Client({ name: 'test-client', version: '1.0.0' });
2020+
const client = new Client(
2021+
{ name: 'test-client', version: '1.0.0' },
2022+
{
2023+
listChanged: {
2024+
tools: {
2025+
debounceMs: 0,
2026+
onChanged: (error, tools) => {
2027+
listChangedNotifications.push([error, tools]);
2028+
resolveListChangedNotification();
2029+
}
2030+
}
2031+
}
2032+
}
2033+
);
20012034
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
20022035
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
20032036

20042037
// listTools() must NOT reject just because one tool's schema is uncompilable.
20052038
const firstPage = await client.listTools();
20062039
expect(firstPage.tools.map(t => t.name).toSorted()).toEqual(['bad-tool', 'validated-tool']);
20072040
const secondPage = await client.listTools({ cursor: firstPage.nextCursor });
2008-
expect(secondPage.tools.map(t => t.name)).toEqual(['last-page-tool']);
2041+
expect(secondPage.tools.map(t => t.name).toSorted()).toEqual(['last-page-bad-tool', 'last-page-tool']);
20092042

20102043
// The final page's tool is fully usable.
20112044
const lastPage = await client.callTool({ name: 'last-page-tool' });
@@ -2016,6 +2049,30 @@ describe('outputSchema validation', () => {
20162049

20172050
// An earlier-page schema compile error also survives pagination and is scoped to that tool.
20182051
await expect(client.callTool({ name: 'bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i);
2052+
2053+
// A final-page schema compile error also survives pagination and is scoped to that tool.
2054+
await expect(client.callTool({ name: 'last-page-bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i);
2055+
expect(lastPageBadToolCalled).toBe(false);
2056+
2057+
listRequests.length = 0;
2058+
lastPageToolReturnsInvalid = true;
2059+
await server.notification({ method: 'notifications/tools/list_changed' });
2060+
await listChangedNotification;
2061+
2062+
expect(listRequests).toEqual([undefined, 'page-2']);
2063+
expect(listChangedNotifications).toHaveLength(1);
2064+
expect(listChangedNotifications[0]![0]).toBeNull();
2065+
expect(listChangedNotifications[0]![1]?.map(t => t.name).toSorted()).toEqual([
2066+
'bad-tool',
2067+
'last-page-bad-tool',
2068+
'last-page-tool',
2069+
'validated-tool'
2070+
]);
2071+
2072+
await expect(client.callTool({ name: 'last-page-tool' })).rejects.toThrow(/Structured content does not match/);
2073+
lastPageBadToolCalled = false;
2074+
await expect(client.callTool({ name: 'last-page-bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i);
2075+
expect(lastPageBadToolCalled).toBe(false);
20192076
});
20202077

20212078
/***

0 commit comments

Comments
 (0)