Skip to content

Commit 1a3ce16

Browse files
MQ37jirispilka
andauthored
feat: normal Actor tools cache (#117)
* cache normal Actor tools * Update src/tools/actor.ts Co-authored-by: Jiří Spilka <[email protected]> * move cache to top, rename var and add clarification, add search integration test * lint --------- Co-authored-by: Jiří Spilka <[email protected]>
1 parent 5cbd423 commit 1a3ce16

File tree

8 files changed

+74
-25
lines changed

8 files changed

+74
-25
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"model context protocol"
3131
],
3232
"dependencies": {
33+
"@apify/datastructures": "^2.0.3",
3334
"@apify/log": "^2.5.16",
3435
"@modelcontextprotocol/sdk": "^1.10.1",
3536
"ajv": "^8.17.1",

src/const.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ export const defaults = {
4545
};
4646

4747
export const APIFY_USERNAME = 'apify';
48+
49+
export const TOOL_CACHE_MAX_SIZE = 500;
50+
export const TOOL_CACHE_TTL_SECS = 30 * 60;

src/mcp/actors.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ActorDefinition } from 'apify-client';
22

3-
import { ApifyClient, getApifyAPIBaseUrl } from '../apify-client.js';
3+
import { ApifyClient } from '../apify-client.js';
44

55
export async function isActorMCPServer(actorID: string, apifyToken: string): Promise<boolean> {
66
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
@@ -59,23 +59,8 @@ export async function getActorStandbyURL(actorID: string, apifyToken: string, st
5959
export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
6060
const apifyClient = new ApifyClient({ token: apifyToken });
6161
const actor = apifyClient.actor(actorID);
62-
const info = await actor.get();
63-
if (!info) {
64-
throw new Error(`Actor ${actorID} not found`);
65-
}
66-
67-
const actorObjID = info.id;
68-
const res = await fetch(`${getApifyAPIBaseUrl()}/v2/acts/${actorObjID}/builds/default`, {
69-
headers: {
70-
// This is done so tests can pass with public Actors without token
71-
...(apifyToken ? { Authorization: `Bearer ${apifyToken}` } : {}),
72-
},
73-
});
74-
if (!res.ok) {
75-
throw new Error(`Failed to fetch default build for actor ${actorID}: ${res.statusText}`);
76-
}
77-
const json = await res.json() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
78-
const buildInfo = json.data;
62+
const defaultBuildClient = await actor.defaultBuild();
63+
const buildInfo = await defaultBuildClient.get();
7964
if (!buildInfo) {
8065
throw new Error(`Default build for Actor ${actorID} not found`);
8166
}

src/tools/actor.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
22
import { Ajv } from 'ajv';
33
import type { ActorCallOptions } from 'apify-client';
44

5+
import { LruCache } from '@apify/datastructures';
56
import log from '@apify/log';
67

78
import { ApifyClient } from '../apify-client.js';
8-
import { ACTOR_ADDITIONAL_INSTRUCTIONS, ACTOR_MAX_MEMORY_MBYTES } from '../const.js';
9+
import { ACTOR_ADDITIONAL_INSTRUCTIONS, ACTOR_MAX_MEMORY_MBYTES, TOOL_CACHE_MAX_SIZE, TOOL_CACHE_TTL_SECS } from '../const.js';
910
import { getActorsMCPServerURL, isActorMCPServer } from '../mcp/actors.js';
1011
import { createMCPClient } from '../mcp/client.js';
1112
import { getMCPServerTools } from '../mcp/proxy.js';
12-
import type { ToolWrap } from '../types.js';
13+
import type { ToolCacheEntry, ToolWrap } from '../types.js';
1314
import { getActorDefinition } from './build.js';
1415
import {
1516
actorNameToToolName,
@@ -20,6 +21,11 @@ import {
2021
shortenProperties,
2122
} from './utils.js';
2223

24+
// Cache for normal Actor tools
25+
const normalActorToolsCache = new LruCache<ToolCacheEntry>({
26+
maxLength: TOOL_CACHE_MAX_SIZE,
27+
});
28+
2329
/**
2430
* Calls an Apify actor and retrieves the dataset items.
2531
*
@@ -83,13 +89,35 @@ export async function getNormalActorsAsTools(
8389
actors: string[],
8490
apifyToken: string,
8591
): Promise<ToolWrap[]> {
92+
const tools: ToolWrap[] = [];
93+
const actorsToLoad: string[] = [];
94+
for (const actorID of actors) {
95+
const cacheEntry = normalActorToolsCache.get(actorID);
96+
if (cacheEntry && cacheEntry.expiresAt > Date.now()) {
97+
tools.push(cacheEntry.tool);
98+
} else {
99+
actorsToLoad.push(actorID);
100+
}
101+
}
102+
if (actorsToLoad.length === 0) {
103+
return tools;
104+
}
105+
86106
const ajv = new Ajv({ coerceTypes: 'array', strict: false });
87107
const getActorDefinitionWithToken = async (actorId: string) => {
88108
return await getActorDefinition(actorId, apifyToken);
89109
};
90-
const results = await Promise.all(actors.map(getActorDefinitionWithToken));
91-
const tools: ToolWrap[] = [];
92-
for (const result of results) {
110+
const results = await Promise.all(actorsToLoad.map(getActorDefinitionWithToken));
111+
112+
// Zip the results with their corresponding actorIDs
113+
for (let i = 0; i < results.length; i++) {
114+
const result = results[i];
115+
// We need to get the orignal input from the user
116+
// sonce the user can input real Actor ID like '3ox4R101TgZz67sLr' instead of
117+
// 'username/actorName' even though we encourage that.
118+
// And the getActorDefinition does not return the original input it received, just the actorFullName or actorID
119+
const actorIDOrName = actorsToLoad[i];
120+
93121
if (result) {
94122
if (result.input && 'properties' in result.input && result.input) {
95123
result.input.properties = markInputPropertiesAsRequired(result.input);
@@ -100,7 +128,7 @@ export async function getNormalActorsAsTools(
100128
}
101129
try {
102130
const memoryMbytes = result.defaultRunOptions?.memoryMbytes || ACTOR_MAX_MEMORY_MBYTES;
103-
tools.push({
131+
const tool: ToolWrap = {
104132
type: 'actor',
105133
tool: {
106134
name: actorNameToToolName(result.actorFullName),
@@ -110,6 +138,11 @@ export async function getNormalActorsAsTools(
110138
ajvValidate: ajv.compile(result.input || {}),
111139
memoryMbytes: memoryMbytes > ACTOR_MAX_MEMORY_MBYTES ? ACTOR_MAX_MEMORY_MBYTES : memoryMbytes,
112140
},
141+
};
142+
tools.push(tool);
143+
normalActorToolsCache.add(actorIDOrName, {
144+
tool,
145+
expiresAt: Date.now() + TOOL_CACHE_TTL_SECS * 1000,
113146
});
114147
} catch (validationError) {
115148
log.error(`Failed to compile AJV schema for Actor: ${result.actorFullName}. Error: ${validationError}`);

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,8 @@ export type Input = {
184184
debugActor?: string;
185185
debugActorInput?: unknown;
186186
};
187+
188+
export interface ToolCacheEntry {
189+
expiresAt: number;
190+
tool: ToolWrap;
191+
}

tests/integration/suite.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,27 @@ export function createIntegrationTestsSuite(
167167
await client.close();
168168
});
169169

170+
it('should search for Actor successfully', async () => {
171+
const query = 'python-example';
172+
const actorName = 'apify/python-example';
173+
const client = await createClientFn({
174+
enableAddingActors: false,
175+
});
176+
177+
// Remove the actor
178+
const result = await client.callTool({
179+
name: HelperTools.SEARCH_ACTORS,
180+
arguments: {
181+
search: query,
182+
limit: 5,
183+
},
184+
});
185+
const content = result.content as {text: string}[];
186+
expect(content.some((item) => item.text.includes(actorName))).toBe(true);
187+
188+
await client.close();
189+
});
190+
170191
it('should remove Actor from tools list', async () => {
171192
const actor = 'apify/python-example';
172193
const selectedToolName = actorNameToToolName(actor);

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ export default defineConfig({
77
globals: true,
88
environment: 'node',
99
include: ['tests/**/*.test.ts'],
10-
testTimeout: 60_000, // 1 minute
10+
testTimeout: 120_000,
1111
},
1212
});

0 commit comments

Comments
 (0)