Skip to content
Merged
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/social-sloths-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openai/agents-core': patch
---

feat: #695 Customizable MCP list tool caching
1 change: 1 addition & 0 deletions packages/agents-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export {
MCPServerStreamableHttp,
MCPServerSSE,
GetAllMcpToolsOptions,
MCPToolCacheKeyGenerator,
} from './mcp';
export {
MCPToolFilterCallable,
Expand Down
68 changes: 59 additions & 9 deletions packages/agents-core/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,54 @@ export class MCPServerSSE extends BaseMCPServerSSE {
*/

const _cachedTools: Record<string, MCPTool[]> = {};
const _cachedToolKeysByServer: Record<string, Set<string>> = {};
/**
* 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];
}
}
}

/**
* 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<any, any>;
runContext?: RunContext<any>;
}) => 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.
*/
Expand All @@ -309,14 +349,22 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>({
convertSchemasToStrict,
runContext,
agent,
generateMCPToolCacheKey,
}: {
server: MCPServer;
convertSchemasToStrict: boolean;
runContext?: RunContext<TContext>;
agent?: Agent<any, any>;
generateMCPToolCacheKey?: MCPToolCacheKeyGenerator;
}): Promise<FunctionTool<TContext, any, unknown>[]> {
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),
);
}
Expand Down Expand Up @@ -379,8 +427,13 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>({
const tools: FunctionTool<TContext, any, string>[] = mcpTools.map((t) =>
mcpToFunctionTool(t, server, convertSchemasToStrict),
);
// Cache store
if (server.cacheToolsList) {
_cachedTools[server.name] = mcpTools;
_cachedTools[cacheKey] = mcpTools;
if (!_cachedToolKeysByServer[server.name]) {
_cachedToolKeysByServer[server.name] = new Set();
}
_cachedToolKeysByServer[server.name].add(cacheKey);
}
return tools;
};
Expand All @@ -402,18 +455,13 @@ export type GetAllMcpToolsOptions<TContext> = {
convertSchemasToStrict?: boolean;
runContext?: RunContext<TContext>;
agent?: Agent<TContext, any>;
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<TContext = UnknownContext>(
mcpServers: MCPServer[],
): Promise<Tool<TContext>[]>;
export async function getAllMcpTools<TContext = UnknownContext>(
opts: GetAllMcpToolsOptions<TContext>,
): Promise<Tool<TContext>[]>;
export async function getAllMcpTools<TContext = UnknownContext>(
mcpServersOrOpts: MCPServer[] | GetAllMcpToolsOptions<TContext>,
runContext?: RunContext<TContext>,
Expand All @@ -434,6 +482,7 @@ export async function getAllMcpTools<TContext = UnknownContext>(
convertSchemasToStrict: convertSchemasToStrictFromOpts = false,
runContext: runContextFromOpts,
agent: agentFromOpts,
generateMCPToolCacheKey,
} = opts;
const allTools: Tool<TContext>[] = [];
const toolNames = new Set<string>();
Expand All @@ -444,6 +493,7 @@ export async function getAllMcpTools<TContext = UnknownContext>(
convertSchemasToStrict: convertSchemasToStrictFromOpts,
runContext: runContextFromOpts,
agent: agentFromOpts,
generateMCPToolCacheKey,
});
const serverToolNames = new Set(serverTools.map((t) => t.name));
const intersection = [...serverToolNames].filter((n) => toolNames.has(n));
Expand Down
204 changes: 204 additions & 0 deletions packages/agents-core/test/mcpCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,208 @@ 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', () => {
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']);
});
});
});