From bb8946c78ccc72a73d9dba57ed6d42c61dcf1e86 Mon Sep 17 00:00:00 2001 From: MQ Date: Thu, 15 May 2025 11:29:13 +0200 Subject: [PATCH 1/4] cache normal Actor tools --- package-lock.json | 1 + package.json | 1 + src/const.ts | 3 +++ src/mcp/actors.ts | 22 ++++------------------ src/tools/actor.ts | 40 ++++++++++++++++++++++++++++++++++------ src/types.ts | 5 +++++ 6 files changed, 48 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45bfdac5..d0c74c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.31", "license": "MIT", "dependencies": { + "@apify/datastructures": "^2.0.3", "@apify/log": "^2.5.16", "@modelcontextprotocol/sdk": "^1.10.1", "ajv": "^8.17.1", diff --git a/package.json b/package.json index cfbd1117..ecba6d3f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "model context protocol" ], "dependencies": { + "@apify/datastructures": "^2.0.3", "@apify/log": "^2.5.16", "@modelcontextprotocol/sdk": "^1.10.1", "ajv": "^8.17.1", diff --git a/src/const.ts b/src/const.ts index 67a376e7..0ad75336 100644 --- a/src/const.ts +++ b/src/const.ts @@ -45,3 +45,6 @@ export const defaults = { }; export const APIFY_USERNAME = 'apify'; + +export const TOOL_CACHE_MAX_SIZE = 500; +export const TOOL_CACHE_TTL_SECS = 30 * 60; diff --git a/src/mcp/actors.ts b/src/mcp/actors.ts index b5a45044..17841235 100644 --- a/src/mcp/actors.ts +++ b/src/mcp/actors.ts @@ -1,6 +1,6 @@ import type { ActorDefinition } from 'apify-client'; -import { ApifyClient, getApifyAPIBaseUrl } from '../apify-client.js'; +import { ApifyClient } from '../apify-client.js'; export async function isActorMCPServer(actorID: string, apifyToken: string): Promise { const mcpPath = await getActorsMCPServerPath(actorID, apifyToken); @@ -56,26 +56,12 @@ export async function getActorStandbyURL(actorID: string, apifyToken: string, st return `https://${actorRealID}.${standbyBaseUrl}`; } +// TODO: optimize this to only use /builds/default endpoint with Actor name replacing / with ~ export async function getActorDefinition(actorID: string, apifyToken: string): Promise { const apifyClient = new ApifyClient({ token: apifyToken }); const actor = apifyClient.actor(actorID); - const info = await actor.get(); - if (!info) { - throw new Error(`Actor ${actorID} not found`); - } - - const actorObjID = info.id; - const res = await fetch(`${getApifyAPIBaseUrl()}/v2/acts/${actorObjID}/builds/default`, { - headers: { - // This is done so tests can pass with public Actors without token - ...(apifyToken ? { Authorization: `Bearer ${apifyToken}` } : {}), - }, - }); - if (!res.ok) { - throw new Error(`Failed to fetch default build for actor ${actorID}: ${res.statusText}`); - } - const json = await res.json() as any; // eslint-disable-line @typescript-eslint/no-explicit-any - const buildInfo = json.data; + const defaultBuildClient = await actor.defaultBuild(); + const buildInfo = await defaultBuildClient.get(); if (!buildInfo) { throw new Error(`Default build for Actor ${actorID} not found`); } diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 98032cf2..a5d129dd 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -2,14 +2,15 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Ajv } from 'ajv'; import type { ActorCallOptions } from 'apify-client'; +import { LruCache } from '@apify/datastructures'; import log from '@apify/log'; import { ApifyClient } from '../apify-client.js'; -import { ACTOR_ADDITIONAL_INSTRUCTIONS, ACTOR_MAX_MEMORY_MBYTES } from '../const.js'; +import { ACTOR_ADDITIONAL_INSTRUCTIONS, ACTOR_MAX_MEMORY_MBYTES, TOOL_CACHE_MAX_SIZE, TOOL_CACHE_TTL_SECS } from '../const.js'; import { getActorsMCPServerURL, isActorMCPServer } from '../mcp/actors.js'; import { createMCPClient } from '../mcp/client.js'; import { getMCPServerTools } from '../mcp/proxy.js'; -import type { ToolWrap } from '../types.js'; +import type { ToolCacheEntry, ToolWrap } from '../types.js'; import { getActorDefinition } from './build.js'; import { actorNameToToolName, @@ -58,6 +59,10 @@ export async function callActorGetDataset( } } +// Cache for normal Actor tools +const normalActorToolsCache = new LruCache({ + maxLength: TOOL_CACHE_MAX_SIZE, +}); /** * This function is used to fetch normal non-MCP server Actors as a tool. * @@ -83,13 +88,31 @@ export async function getNormalActorsAsTools( actors: string[], apifyToken: string, ): Promise { + const tools: ToolWrap[] = []; + const actorsLoadedFromCache: string[] = []; + for (const actorID of actors) { + const cacheEntry = normalActorToolsCache.get(actorID); + if (cacheEntry && cacheEntry.expiresAt > Date.now()) { + tools.push(cacheEntry.tool); + actorsLoadedFromCache.push(actorID); + } + } + const actorsToLoad = actors.filter((actorID) => !actorsLoadedFromCache.includes(actorID)); + if (actorsToLoad.length === 0) { + return tools; + } + const ajv = new Ajv({ coerceTypes: 'array', strict: false }); const getActorDefinitionWithToken = async (actorId: string) => { return await getActorDefinition(actorId, apifyToken); }; - const results = await Promise.all(actors.map(getActorDefinitionWithToken)); - const tools: ToolWrap[] = []; - for (const result of results) { + const results = await Promise.all(actorsToLoad.map(getActorDefinitionWithToken)); + + // Zip the results with their corresponding actorIDs + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const actorID = actorsToLoad[i]; + if (result) { if (result.input && 'properties' in result.input && result.input) { result.input.properties = markInputPropertiesAsRequired(result.input); @@ -100,7 +123,7 @@ export async function getNormalActorsAsTools( } try { const memoryMbytes = result.defaultRunOptions?.memoryMbytes || ACTOR_MAX_MEMORY_MBYTES; - tools.push({ + const tool: ToolWrap = { type: 'actor', tool: { name: actorNameToToolName(result.actorFullName), @@ -110,6 +133,11 @@ export async function getNormalActorsAsTools( ajvValidate: ajv.compile(result.input || {}), memoryMbytes: memoryMbytes > ACTOR_MAX_MEMORY_MBYTES ? ACTOR_MAX_MEMORY_MBYTES : memoryMbytes, }, + }; + tools.push(tool); + normalActorToolsCache.add(actorID, { + tool, + expiresAt: Date.now() + TOOL_CACHE_TTL_SECS * 1000, }); } catch (validationError) { log.error(`Failed to compile AJV schema for Actor: ${result.actorFullName}. Error: ${validationError}`); diff --git a/src/types.ts b/src/types.ts index becf1fc8..e7cb4ead 100644 --- a/src/types.ts +++ b/src/types.ts @@ -184,3 +184,8 @@ export type Input = { debugActor?: string; debugActorInput?: unknown; }; + +export interface ToolCacheEntry { + expiresAt: number; + tool: ToolWrap; +} From 37949a7d1088d532f7621cd5e9c1c6f4e0bdc7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Fri, 16 May 2025 12:47:18 +0200 Subject: [PATCH 2/4] Update src/tools/actor.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiří Spilka --- src/tools/actor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index a5d129dd..f1ed2496 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -89,15 +89,15 @@ export async function getNormalActorsAsTools( apifyToken: string, ): Promise { const tools: ToolWrap[] = []; - const actorsLoadedFromCache: string[] = []; + const actorsToLoad: string[] = []; for (const actorID of actors) { const cacheEntry = normalActorToolsCache.get(actorID); if (cacheEntry && cacheEntry.expiresAt > Date.now()) { tools.push(cacheEntry.tool); - actorsLoadedFromCache.push(actorID); + } else { + actorsToLoad.push(actorID); } } - const actorsToLoad = actors.filter((actorID) => !actorsLoadedFromCache.includes(actorID)); if (actorsToLoad.length === 0) { return tools; } From ec92cdef8fea310b27891dbc5b7d6030a2986567 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 16 May 2025 13:34:32 +0200 Subject: [PATCH 3/4] move cache to top, rename var and add clarification, add search integration test --- src/mcp/actors.ts | 1 - src/tools/actor.ts | 18 ++++++++++++------ tests/integration/suite.ts | 21 +++++++++++++++++++++ vitest.config.ts | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/mcp/actors.ts b/src/mcp/actors.ts index 17841235..39c86422 100644 --- a/src/mcp/actors.ts +++ b/src/mcp/actors.ts @@ -56,7 +56,6 @@ export async function getActorStandbyURL(actorID: string, apifyToken: string, st return `https://${actorRealID}.${standbyBaseUrl}`; } -// TODO: optimize this to only use /builds/default endpoint with Actor name replacing / with ~ export async function getActorDefinition(actorID: string, apifyToken: string): Promise { const apifyClient = new ApifyClient({ token: apifyToken }); const actor = apifyClient.actor(actorID); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index f1ed2496..92a04c13 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -21,6 +21,12 @@ import { shortenProperties, } from './utils.js'; + +// Cache for normal Actor tools +const normalActorToolsCache = new LruCache({ + maxLength: TOOL_CACHE_MAX_SIZE, +}); + /** * Calls an Apify actor and retrieves the dataset items. * @@ -59,10 +65,6 @@ export async function callActorGetDataset( } } -// Cache for normal Actor tools -const normalActorToolsCache = new LruCache({ - maxLength: TOOL_CACHE_MAX_SIZE, -}); /** * This function is used to fetch normal non-MCP server Actors as a tool. * @@ -111,7 +113,11 @@ export async function getNormalActorsAsTools( // Zip the results with their corresponding actorIDs for (let i = 0; i < results.length; i++) { const result = results[i]; - const actorID = actorsToLoad[i]; + // We need to get the orignal input from the user + // sonce the user can input real Actor ID like '3ox4R101TgZz67sLr' instead of + // 'username/actorName' even though we encourage that. + // And the getActorDefinition does not return the original input it received, just the actorFullName or actorID + const actorIDOrName = actorsToLoad[i]; if (result) { if (result.input && 'properties' in result.input && result.input) { @@ -135,7 +141,7 @@ export async function getNormalActorsAsTools( }, }; tools.push(tool); - normalActorToolsCache.add(actorID, { + normalActorToolsCache.add(actorIDOrName, { tool, expiresAt: Date.now() + TOOL_CACHE_TTL_SECS * 1000, }); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index e158d656..f2c12151 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -167,6 +167,27 @@ export function createIntegrationTestsSuite( await client.close(); }); + it('should search for Actor successfully', async () => { + const query = 'python-example'; + const actorName = 'apify/python-example'; + const client = await createClientFn({ + enableAddingActors: false, + }); + + // Remove the actor + const result = await client.callTool({ + name: HelperTools.SEARCH_ACTORS, + arguments: { + search: query, + limit: 5, + }, + }); + const content = result.content as {text: string}[]; + expect(content.some((item) => item.text.includes(actorName))).toBe(true); + + await client.close(); + }); + it('should remove Actor from tools list', async () => { const actor = 'apify/python-example'; const selectedToolName = actorNameToToolName(actor); diff --git a/vitest.config.ts b/vitest.config.ts index e604a359..677cef0f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['tests/**/*.test.ts'], - testTimeout: 60_000, // 1 minute + testTimeout: 120_000, }, }); From ce0e029234fd076309dd151a17ccaab28a9b0872 Mon Sep 17 00:00:00 2001 From: MQ Date: Fri, 16 May 2025 13:35:33 +0200 Subject: [PATCH 4/4] lint --- src/tools/actor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 92a04c13..9ebeeb64 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -21,7 +21,6 @@ import { shortenProperties, } from './utils.js'; - // Cache for normal Actor tools const normalActorToolsCache = new LruCache({ maxLength: TOOL_CACHE_MAX_SIZE,