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..39c86422 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); @@ -59,23 +59,8 @@ export async function getActorStandbyURL(actorID: string, apifyToken: string, st 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..9ebeeb64 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, @@ -20,6 +21,11 @@ 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. * @@ -83,13 +89,35 @@ export async function getNormalActorsAsTools( actors: string[], apifyToken: string, ): Promise { + const tools: ToolWrap[] = []; + const actorsToLoad: string[] = []; + for (const actorID of actors) { + const cacheEntry = normalActorToolsCache.get(actorID); + if (cacheEntry && cacheEntry.expiresAt > Date.now()) { + tools.push(cacheEntry.tool); + } else { + actorsToLoad.push(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]; + // 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) { result.input.properties = markInputPropertiesAsRequired(result.input); @@ -100,7 +128,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 +138,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(actorIDOrName, { + 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; +} 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, }, });