From 1b6586e57276165e1bdf5ad58aa8d7ac2b9c8a3e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 2 Dec 2025 15:52:56 +0900 Subject: [PATCH 1/2] feat: #695 Customizable MCP list tool caching --- .changeset/social-sloths-make.md | 5 + packages/agents-core/src/index.ts | 1 + packages/agents-core/src/mcp.ts | 49 ++++++-- packages/agents-core/test/mcpCache.test.ts | 123 +++++++++++++++++++++ 4 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 .changeset/social-sloths-make.md diff --git a/.changeset/social-sloths-make.md b/.changeset/social-sloths-make.md new file mode 100644 index 00000000..b4ba50d7 --- /dev/null +++ b/.changeset/social-sloths-make.md @@ -0,0 +1,5 @@ +--- +'@openai/agents-core': patch +--- + +feat: #695 Customizable MCP list tool caching diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index e43cda52..b993c732 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -80,6 +80,7 @@ export { MCPServerStreamableHttp, MCPServerSSE, GetAllMcpToolsOptions, + MCPToolCacheKeyGenerator, } from './mcp'; export { MCPToolFilterCallable, diff --git a/packages/agents-core/src/mcp.ts b/packages/agents-core/src/mcp.ts index 1bb14112..3c0c9c04 100644 --- a/packages/agents-core/src/mcp.ts +++ b/packages/agents-core/src/mcp.ts @@ -301,6 +301,31 @@ const _cachedTools: Record = {}; export async function invalidateServerToolsCache(serverName: string) { delete _cachedTools[serverName]; } + +/** + * Function signature for generating the MCP tool cache key. + * Customizable so the cache key can depend on any context—server, agent, runContext, etc. + */ +export type MCPToolCacheKeyGenerator = (params: { + server: MCPServer; + agent?: Agent; + runContext?: RunContext; +}) => string; + +/** + * Default cache key generator for MCP tools. + * Uses server name, or server+agent if using callable filter. + */ +export const defaultMCPToolCacheKey: MCPToolCacheKeyGenerator = ({ + server, + agent, +}) => { + if (server.toolFilter && typeof server.toolFilter === 'function' && agent) { + return `${server.name}:${agent.name}`; + } + return server.name; +}; + /** * Fetches all function tools from a single MCP server. */ @@ -309,14 +334,22 @@ async function getFunctionToolsFromServer({ convertSchemasToStrict, runContext, agent, + generateMCPToolCacheKey, }: { server: MCPServer; convertSchemasToStrict: boolean; runContext?: RunContext; agent?: Agent; + generateMCPToolCacheKey?: MCPToolCacheKeyGenerator; }): Promise[]> { - if (server.cacheToolsList && _cachedTools[server.name]) { - return _cachedTools[server.name].map((t) => + const cacheKey = (generateMCPToolCacheKey || defaultMCPToolCacheKey)({ + server, + agent, + runContext, + }); + // Use cache key generator injected from the outside, or the default if absent. + if (server.cacheToolsList && _cachedTools[cacheKey]) { + return _cachedTools[cacheKey].map((t) => mcpToFunctionTool(t, server, convertSchemasToStrict), ); } @@ -379,8 +412,9 @@ async function getFunctionToolsFromServer({ const tools: FunctionTool[] = mcpTools.map((t) => mcpToFunctionTool(t, server, convertSchemasToStrict), ); + // Cache store if (server.cacheToolsList) { - _cachedTools[server.name] = mcpTools; + _cachedTools[cacheKey] = mcpTools; } return tools; }; @@ -402,18 +436,13 @@ export type GetAllMcpToolsOptions = { convertSchemasToStrict?: boolean; runContext?: RunContext; agent?: Agent; + generateMCPToolCacheKey?: MCPToolCacheKeyGenerator; }; /** * Returns all MCP tools from the provided servers, using the function tool conversion. * If runContext and agent are provided, callable tool filters will be applied. */ -export async function getAllMcpTools( - mcpServers: MCPServer[], -): Promise[]>; -export async function getAllMcpTools( - opts: GetAllMcpToolsOptions, -): Promise[]>; export async function getAllMcpTools( mcpServersOrOpts: MCPServer[] | GetAllMcpToolsOptions, runContext?: RunContext, @@ -434,6 +463,7 @@ export async function getAllMcpTools( convertSchemasToStrict: convertSchemasToStrictFromOpts = false, runContext: runContextFromOpts, agent: agentFromOpts, + generateMCPToolCacheKey, } = opts; const allTools: Tool[] = []; const toolNames = new Set(); @@ -444,6 +474,7 @@ export async function getAllMcpTools( convertSchemasToStrict: convertSchemasToStrictFromOpts, runContext: runContextFromOpts, agent: agentFromOpts, + generateMCPToolCacheKey, }); const serverToolNames = new Set(serverTools.map((t) => t.name)); const intersection = [...serverToolNames].filter((n) => toolNames.has(n)); diff --git a/packages/agents-core/test/mcpCache.test.ts b/packages/agents-core/test/mcpCache.test.ts index 42c83eb5..3395f48e 100644 --- a/packages/agents-core/test/mcpCache.test.ts +++ b/packages/agents-core/test/mcpCache.test.ts @@ -111,3 +111,126 @@ describe('MCP tools cache invalidation', () => { }); }); }); + +describe('MCP tools agent-dependent cache behavior', () => { + it('handles agent-specific callable tool filters without cache leaking between agents', async () => { + await withTrace('test', async () => { + const tools = [ + { + name: 'foo', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'bar', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + + // Callable filter chooses tool availability per agent name + const filter = async (ctx: any, tool: any) => { + if (ctx.agent.name === 'AgentOne') { + return tool.name === 'foo'; // AgentOne: only 'foo' allowed + } else { + return tool.name === 'bar'; // AgentTwo: only 'bar' allowed + } + }; + const server = new StubServer('shared-server', tools); + server.toolFilter = filter; + + const agentOne = new Agent({ name: 'AgentOne' }); + const agentTwo = new Agent({ name: 'AgentTwo' }); + const ctxOne = new RunContext({}); + const ctxTwo = new RunContext({}); + + // First access by AgentOne (should get only 'foo') + const result1 = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxOne, + agent: agentOne, + }); + expect(result1.map((t: any) => t.name)).toEqual(['foo']); + + // Second access by AgentTwo (should get only 'bar') + const result2 = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxTwo, + agent: agentTwo, + }); + expect(result2.map((t: any) => t.name)).toEqual(['bar']); + + // Third access by AgentOne (should still get only 'foo', from cache key) + const result3 = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxOne, + agent: agentOne, + }); + expect(result3.map((t: any) => t.name)).toEqual(['foo']); + }); + }); +}); + +describe('Custom generateMCPToolCacheKey can include runContext in key', () => { + it('supports fully custom cache key logic, including runContext properties', async () => { + await withTrace('test', async () => { + const tools = [ + { + name: 'foo', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'bar', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + // Filter that allows a tool based on runContext meta value + const filter = async (ctx: any, tool: any) => { + if (ctx.runContext.meta && ctx.runContext.meta.kind === 'fooUser') { + return tool.name === 'foo'; + } else { + return tool.name === 'bar'; + } + }; + const server = new StubServer('custom-key-srv', tools); + server.toolFilter = filter; + const agent = new Agent({ name: 'A' }); + // This cache key generator uses both agent name and runContext.meta.kind + const generateMCPToolCacheKey = ({ server, agent, runContext }: any) => + `${server.name}:${agent ? agent.name : ''}:${runContext?.meta?.kind}`; + + // Agent 'A', runContext kind 'fooUser' => should see only 'foo' + const context1 = new RunContext({}); + (context1 as any).meta = { kind: 'fooUser' }; + const res1 = await getAllMcpTools({ + mcpServers: [server], + runContext: context1, + agent, + generateMCPToolCacheKey, + }); + expect(res1.map((t: any) => t.name)).toEqual(['foo']); + + // Agent 'A', runContext kind 'barUser' => should see only 'bar' + const context2 = new RunContext({}); + (context2 as any).meta = { kind: 'barUser' }; + const res2 = await getAllMcpTools({ + mcpServers: [server], + runContext: context2, + agent, + generateMCPToolCacheKey, + }); + expect(res2.map((t: any) => t.name)).toEqual(['bar']); + + // Agent 'A'/'fooUser' again => should hit the correct cache entry, still see only 'foo' + const res3 = await getAllMcpTools({ + mcpServers: [server], + runContext: context1, + agent, + generateMCPToolCacheKey, + }); + expect(res3.map((t: any) => t.name)).toEqual(['foo']); + }); + }); +}); From 0626b512b87cfebd37b320b9e27f312dc7f83a8c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 2 Dec 2025 16:02:54 +0900 Subject: [PATCH 2/2] fix invalidation issue --- packages/agents-core/src/mcp.ts | 19 +++++ packages/agents-core/test/mcpCache.test.ts | 81 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/agents-core/src/mcp.ts b/packages/agents-core/src/mcp.ts index 3c0c9c04..6a168717 100644 --- a/packages/agents-core/src/mcp.ts +++ b/packages/agents-core/src/mcp.ts @@ -293,13 +293,28 @@ export class MCPServerSSE extends BaseMCPServerSSE { */ const _cachedTools: Record = {}; +const _cachedToolKeysByServer: Record> = {}; /** * Remove cached tools for the given server so the next lookup fetches fresh data. * * @param serverName - Name of the MCP server whose cache should be cleared. */ export async function invalidateServerToolsCache(serverName: string) { + const cachedKeys = _cachedToolKeysByServer[serverName]; + if (cachedKeys) { + for (const cacheKey of cachedKeys) { + delete _cachedTools[cacheKey]; + } + delete _cachedToolKeysByServer[serverName]; + return; + } + delete _cachedTools[serverName]; + for (const cacheKey of Object.keys(_cachedTools)) { + if (cacheKey.startsWith(`${serverName}:`)) { + delete _cachedTools[cacheKey]; + } + } } /** @@ -415,6 +430,10 @@ async function getFunctionToolsFromServer({ // Cache store if (server.cacheToolsList) { _cachedTools[cacheKey] = mcpTools; + if (!_cachedToolKeysByServer[server.name]) { + _cachedToolKeysByServer[server.name] = new Set(); + } + _cachedToolKeysByServer[server.name].add(cacheKey); } return tools; }; diff --git a/packages/agents-core/test/mcpCache.test.ts b/packages/agents-core/test/mcpCache.test.ts index 3395f48e..682b7768 100644 --- a/packages/agents-core/test/mcpCache.test.ts +++ b/packages/agents-core/test/mcpCache.test.ts @@ -110,6 +110,87 @@ describe('MCP tools cache invalidation', () => { expect(called).toBe(true); }); }); + + it('clears agent-specific cache entries when cache is invalidated', async () => { + await withTrace('test', async () => { + const toolsInitial = [ + { + name: 'foo_initial', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'bar_initial', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + const toolsUpdated = [ + { + name: 'foo_updated', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'bar_updated', + description: '', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + + const server = new StubServer('server', toolsInitial); + server.toolFilter = async (ctx: any, tool: any) => { + if (ctx.agent.name === 'AgentOne') { + return tool.name.startsWith('foo'); + } + return tool.name.startsWith('bar'); + }; + + const agentOne = new Agent({ name: 'AgentOne' }); + const agentTwo = new Agent({ name: 'AgentTwo' }); + const ctxOne = new RunContext({}); + const ctxTwo = new RunContext({}); + + const initialToolsAgentOne = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxOne, + agent: agentOne, + }); + expect(initialToolsAgentOne.map((t: any) => t.name)).toEqual([ + 'foo_initial', + ]); + + const initialToolsAgentTwo = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxTwo, + agent: agentTwo, + }); + expect(initialToolsAgentTwo.map((t: any) => t.name)).toEqual([ + 'bar_initial', + ]); + + server.toolList = toolsUpdated; + await server.invalidateToolsCache(); + + const updatedToolsAgentOne = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxOne, + agent: agentOne, + }); + expect(updatedToolsAgentOne.map((t: any) => t.name)).toEqual([ + 'foo_updated', + ]); + + const updatedToolsAgentTwo = await getAllMcpTools({ + mcpServers: [server], + runContext: ctxTwo, + agent: agentTwo, + }); + expect(updatedToolsAgentTwo.map((t: any) => t.name)).toEqual([ + 'bar_updated', + ]); + }); + }); }); describe('MCP tools agent-dependent cache behavior', () => {