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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
21 changes: 3 additions & 18 deletions src/mcp/actors.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
Expand Down Expand Up @@ -59,23 +59,8 @@ export async function getActorStandbyURL(actorID: string, apifyToken: string, st
export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
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`);
}
Expand Down
45 changes: 39 additions & 6 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +21,11 @@ import {
shortenProperties,
} from './utils.js';

// Cache for normal Actor tools
const normalActorToolsCache = new LruCache<ToolCacheEntry>({
maxLength: TOOL_CACHE_MAX_SIZE,
});

/**
* Calls an Apify actor and retrieves the dataset items.
*
Expand Down Expand Up @@ -83,13 +89,35 @@ export async function getNormalActorsAsTools(
actors: string[],
apifyToken: string,
): Promise<ToolWrap[]> {
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);
Expand All @@ -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),
Expand All @@ -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}`);
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,8 @@ export type Input = {
debugActor?: string;
debugActorInput?: unknown;
};

export interface ToolCacheEntry {
expiresAt: number;
tool: ToolWrap;
}
21 changes: 21 additions & 0 deletions tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
testTimeout: 60_000, // 1 minute
testTimeout: 120_000,
},
});