Skip to content

Commit 0345a4c

Browse files
authored
feat: #695 Customizable MCP list tool caching (#715)
1 parent c252cb5 commit 0345a4c

File tree

4 files changed

+269
-9
lines changed

4 files changed

+269
-9
lines changed

.changeset/social-sloths-make.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-core': patch
3+
---
4+
5+
feat: #695 Customizable MCP list tool caching

packages/agents-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export {
8080
MCPServerStreamableHttp,
8181
MCPServerSSE,
8282
GetAllMcpToolsOptions,
83+
MCPToolCacheKeyGenerator,
8384
} from './mcp';
8485
export {
8586
MCPToolFilterCallable,

packages/agents-core/src/mcp.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,14 +293,54 @@ export class MCPServerSSE extends BaseMCPServerSSE {
293293
*/
294294

295295
const _cachedTools: Record<string, MCPTool[]> = {};
296+
const _cachedToolKeysByServer: Record<string, Set<string>> = {};
296297
/**
297298
* Remove cached tools for the given server so the next lookup fetches fresh data.
298299
*
299300
* @param serverName - Name of the MCP server whose cache should be cleared.
300301
*/
301302
export async function invalidateServerToolsCache(serverName: string) {
303+
const cachedKeys = _cachedToolKeysByServer[serverName];
304+
if (cachedKeys) {
305+
for (const cacheKey of cachedKeys) {
306+
delete _cachedTools[cacheKey];
307+
}
308+
delete _cachedToolKeysByServer[serverName];
309+
return;
310+
}
311+
302312
delete _cachedTools[serverName];
313+
for (const cacheKey of Object.keys(_cachedTools)) {
314+
if (cacheKey.startsWith(`${serverName}:`)) {
315+
delete _cachedTools[cacheKey];
316+
}
317+
}
303318
}
319+
320+
/**
321+
* Function signature for generating the MCP tool cache key.
322+
* Customizable so the cache key can depend on any context—server, agent, runContext, etc.
323+
*/
324+
export type MCPToolCacheKeyGenerator = (params: {
325+
server: MCPServer;
326+
agent?: Agent<any, any>;
327+
runContext?: RunContext<any>;
328+
}) => string;
329+
330+
/**
331+
* Default cache key generator for MCP tools.
332+
* Uses server name, or server+agent if using callable filter.
333+
*/
334+
export const defaultMCPToolCacheKey: MCPToolCacheKeyGenerator = ({
335+
server,
336+
agent,
337+
}) => {
338+
if (server.toolFilter && typeof server.toolFilter === 'function' && agent) {
339+
return `${server.name}:${agent.name}`;
340+
}
341+
return server.name;
342+
};
343+
304344
/**
305345
* Fetches all function tools from a single MCP server.
306346
*/
@@ -309,14 +349,22 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>({
309349
convertSchemasToStrict,
310350
runContext,
311351
agent,
352+
generateMCPToolCacheKey,
312353
}: {
313354
server: MCPServer;
314355
convertSchemasToStrict: boolean;
315356
runContext?: RunContext<TContext>;
316357
agent?: Agent<any, any>;
358+
generateMCPToolCacheKey?: MCPToolCacheKeyGenerator;
317359
}): Promise<FunctionTool<TContext, any, unknown>[]> {
318-
if (server.cacheToolsList && _cachedTools[server.name]) {
319-
return _cachedTools[server.name].map((t) =>
360+
const cacheKey = (generateMCPToolCacheKey || defaultMCPToolCacheKey)({
361+
server,
362+
agent,
363+
runContext,
364+
});
365+
// Use cache key generator injected from the outside, or the default if absent.
366+
if (server.cacheToolsList && _cachedTools[cacheKey]) {
367+
return _cachedTools[cacheKey].map((t) =>
320368
mcpToFunctionTool(t, server, convertSchemasToStrict),
321369
);
322370
}
@@ -379,8 +427,13 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>({
379427
const tools: FunctionTool<TContext, any, string>[] = mcpTools.map((t) =>
380428
mcpToFunctionTool(t, server, convertSchemasToStrict),
381429
);
430+
// Cache store
382431
if (server.cacheToolsList) {
383-
_cachedTools[server.name] = mcpTools;
432+
_cachedTools[cacheKey] = mcpTools;
433+
if (!_cachedToolKeysByServer[server.name]) {
434+
_cachedToolKeysByServer[server.name] = new Set();
435+
}
436+
_cachedToolKeysByServer[server.name].add(cacheKey);
384437
}
385438
return tools;
386439
};
@@ -402,18 +455,13 @@ export type GetAllMcpToolsOptions<TContext> = {
402455
convertSchemasToStrict?: boolean;
403456
runContext?: RunContext<TContext>;
404457
agent?: Agent<TContext, any>;
458+
generateMCPToolCacheKey?: MCPToolCacheKeyGenerator;
405459
};
406460

407461
/**
408462
* Returns all MCP tools from the provided servers, using the function tool conversion.
409463
* If runContext and agent are provided, callable tool filters will be applied.
410464
*/
411-
export async function getAllMcpTools<TContext = UnknownContext>(
412-
mcpServers: MCPServer[],
413-
): Promise<Tool<TContext>[]>;
414-
export async function getAllMcpTools<TContext = UnknownContext>(
415-
opts: GetAllMcpToolsOptions<TContext>,
416-
): Promise<Tool<TContext>[]>;
417465
export async function getAllMcpTools<TContext = UnknownContext>(
418466
mcpServersOrOpts: MCPServer[] | GetAllMcpToolsOptions<TContext>,
419467
runContext?: RunContext<TContext>,
@@ -434,6 +482,7 @@ export async function getAllMcpTools<TContext = UnknownContext>(
434482
convertSchemasToStrict: convertSchemasToStrictFromOpts = false,
435483
runContext: runContextFromOpts,
436484
agent: agentFromOpts,
485+
generateMCPToolCacheKey,
437486
} = opts;
438487
const allTools: Tool<TContext>[] = [];
439488
const toolNames = new Set<string>();
@@ -444,6 +493,7 @@ export async function getAllMcpTools<TContext = UnknownContext>(
444493
convertSchemasToStrict: convertSchemasToStrictFromOpts,
445494
runContext: runContextFromOpts,
446495
agent: agentFromOpts,
496+
generateMCPToolCacheKey,
447497
});
448498
const serverToolNames = new Set(serverTools.map((t) => t.name));
449499
const intersection = [...serverToolNames].filter((n) => toolNames.has(n));

packages/agents-core/test/mcpCache.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,208 @@ describe('MCP tools cache invalidation', () => {
110110
expect(called).toBe(true);
111111
});
112112
});
113+
114+
it('clears agent-specific cache entries when cache is invalidated', async () => {
115+
await withTrace('test', async () => {
116+
const toolsInitial = [
117+
{
118+
name: 'foo_initial',
119+
description: '',
120+
inputSchema: { type: 'object', properties: {} },
121+
},
122+
{
123+
name: 'bar_initial',
124+
description: '',
125+
inputSchema: { type: 'object', properties: {} },
126+
},
127+
];
128+
const toolsUpdated = [
129+
{
130+
name: 'foo_updated',
131+
description: '',
132+
inputSchema: { type: 'object', properties: {} },
133+
},
134+
{
135+
name: 'bar_updated',
136+
description: '',
137+
inputSchema: { type: 'object', properties: {} },
138+
},
139+
];
140+
141+
const server = new StubServer('server', toolsInitial);
142+
server.toolFilter = async (ctx: any, tool: any) => {
143+
if (ctx.agent.name === 'AgentOne') {
144+
return tool.name.startsWith('foo');
145+
}
146+
return tool.name.startsWith('bar');
147+
};
148+
149+
const agentOne = new Agent({ name: 'AgentOne' });
150+
const agentTwo = new Agent({ name: 'AgentTwo' });
151+
const ctxOne = new RunContext({});
152+
const ctxTwo = new RunContext({});
153+
154+
const initialToolsAgentOne = await getAllMcpTools({
155+
mcpServers: [server],
156+
runContext: ctxOne,
157+
agent: agentOne,
158+
});
159+
expect(initialToolsAgentOne.map((t: any) => t.name)).toEqual([
160+
'foo_initial',
161+
]);
162+
163+
const initialToolsAgentTwo = await getAllMcpTools({
164+
mcpServers: [server],
165+
runContext: ctxTwo,
166+
agent: agentTwo,
167+
});
168+
expect(initialToolsAgentTwo.map((t: any) => t.name)).toEqual([
169+
'bar_initial',
170+
]);
171+
172+
server.toolList = toolsUpdated;
173+
await server.invalidateToolsCache();
174+
175+
const updatedToolsAgentOne = await getAllMcpTools({
176+
mcpServers: [server],
177+
runContext: ctxOne,
178+
agent: agentOne,
179+
});
180+
expect(updatedToolsAgentOne.map((t: any) => t.name)).toEqual([
181+
'foo_updated',
182+
]);
183+
184+
const updatedToolsAgentTwo = await getAllMcpTools({
185+
mcpServers: [server],
186+
runContext: ctxTwo,
187+
agent: agentTwo,
188+
});
189+
expect(updatedToolsAgentTwo.map((t: any) => t.name)).toEqual([
190+
'bar_updated',
191+
]);
192+
});
193+
});
194+
});
195+
196+
describe('MCP tools agent-dependent cache behavior', () => {
197+
it('handles agent-specific callable tool filters without cache leaking between agents', async () => {
198+
await withTrace('test', async () => {
199+
const tools = [
200+
{
201+
name: 'foo',
202+
description: '',
203+
inputSchema: { type: 'object', properties: {} },
204+
},
205+
{
206+
name: 'bar',
207+
description: '',
208+
inputSchema: { type: 'object', properties: {} },
209+
},
210+
];
211+
212+
// Callable filter chooses tool availability per agent name
213+
const filter = async (ctx: any, tool: any) => {
214+
if (ctx.agent.name === 'AgentOne') {
215+
return tool.name === 'foo'; // AgentOne: only 'foo' allowed
216+
} else {
217+
return tool.name === 'bar'; // AgentTwo: only 'bar' allowed
218+
}
219+
};
220+
const server = new StubServer('shared-server', tools);
221+
server.toolFilter = filter;
222+
223+
const agentOne = new Agent({ name: 'AgentOne' });
224+
const agentTwo = new Agent({ name: 'AgentTwo' });
225+
const ctxOne = new RunContext({});
226+
const ctxTwo = new RunContext({});
227+
228+
// First access by AgentOne (should get only 'foo')
229+
const result1 = await getAllMcpTools({
230+
mcpServers: [server],
231+
runContext: ctxOne,
232+
agent: agentOne,
233+
});
234+
expect(result1.map((t: any) => t.name)).toEqual(['foo']);
235+
236+
// Second access by AgentTwo (should get only 'bar')
237+
const result2 = await getAllMcpTools({
238+
mcpServers: [server],
239+
runContext: ctxTwo,
240+
agent: agentTwo,
241+
});
242+
expect(result2.map((t: any) => t.name)).toEqual(['bar']);
243+
244+
// Third access by AgentOne (should still get only 'foo', from cache key)
245+
const result3 = await getAllMcpTools({
246+
mcpServers: [server],
247+
runContext: ctxOne,
248+
agent: agentOne,
249+
});
250+
expect(result3.map((t: any) => t.name)).toEqual(['foo']);
251+
});
252+
});
253+
});
254+
255+
describe('Custom generateMCPToolCacheKey can include runContext in key', () => {
256+
it('supports fully custom cache key logic, including runContext properties', async () => {
257+
await withTrace('test', async () => {
258+
const tools = [
259+
{
260+
name: 'foo',
261+
description: '',
262+
inputSchema: { type: 'object', properties: {} },
263+
},
264+
{
265+
name: 'bar',
266+
description: '',
267+
inputSchema: { type: 'object', properties: {} },
268+
},
269+
];
270+
// Filter that allows a tool based on runContext meta value
271+
const filter = async (ctx: any, tool: any) => {
272+
if (ctx.runContext.meta && ctx.runContext.meta.kind === 'fooUser') {
273+
return tool.name === 'foo';
274+
} else {
275+
return tool.name === 'bar';
276+
}
277+
};
278+
const server = new StubServer('custom-key-srv', tools);
279+
server.toolFilter = filter;
280+
const agent = new Agent({ name: 'A' });
281+
// This cache key generator uses both agent name and runContext.meta.kind
282+
const generateMCPToolCacheKey = ({ server, agent, runContext }: any) =>
283+
`${server.name}:${agent ? agent.name : ''}:${runContext?.meta?.kind}`;
284+
285+
// Agent 'A', runContext kind 'fooUser' => should see only 'foo'
286+
const context1 = new RunContext({});
287+
(context1 as any).meta = { kind: 'fooUser' };
288+
const res1 = await getAllMcpTools({
289+
mcpServers: [server],
290+
runContext: context1,
291+
agent,
292+
generateMCPToolCacheKey,
293+
});
294+
expect(res1.map((t: any) => t.name)).toEqual(['foo']);
295+
296+
// Agent 'A', runContext kind 'barUser' => should see only 'bar'
297+
const context2 = new RunContext({});
298+
(context2 as any).meta = { kind: 'barUser' };
299+
const res2 = await getAllMcpTools({
300+
mcpServers: [server],
301+
runContext: context2,
302+
agent,
303+
generateMCPToolCacheKey,
304+
});
305+
expect(res2.map((t: any) => t.name)).toEqual(['bar']);
306+
307+
// Agent 'A'/'fooUser' again => should hit the correct cache entry, still see only 'foo'
308+
const res3 = await getAllMcpTools({
309+
mcpServers: [server],
310+
runContext: context1,
311+
agent,
312+
generateMCPToolCacheKey,
313+
});
314+
expect(res3.map((t: any) => t.name)).toEqual(['foo']);
315+
});
316+
});
113317
});

0 commit comments

Comments
 (0)