Skip to content

Commit 3869388

Browse files
committed
fix and improve the call-actor generic tool
1 parent f969ac2 commit 3869388

File tree

4 files changed

+130
-81
lines changed

4 files changed

+130
-81
lines changed

src/tools/actor.ts

Lines changed: 79 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,11 @@ import { getMCPServerTools } from '../mcp/proxy.js';
1818
import { actorDefinitionPrunedCache } from '../state.js';
1919
import type { ActorDefinitionStorage, ActorInfo, ToolEntry } from '../types.js';
2020
import { getActorDefinitionStorageFieldNames } from '../utils/actor.js';
21+
import { fetchActorDetails } from '../utils/actor-details.js';
2122
import { getValuesByDotKeys } from '../utils/generic.js';
2223
import type { ProgressTracker } from '../utils/progress.js';
2324
import { getActorDefinition } from './build.js';
24-
import {
25-
actorNameToToolName,
26-
fixedAjvCompile,
27-
getToolSchemaID,
28-
transformActorInputSchemaProperties,
29-
} from './utils.js';
25+
import { actorNameToToolName, fixedAjvCompile, getToolSchemaID, transformActorInputSchemaProperties } from './utils.js';
3026

3127
const ajv = new Ajv({ coerceTypes: 'array', strict: false });
3228

@@ -141,7 +137,9 @@ export async function getNormalActorsAsTools(
141137
tool: {
142138
name: actorNameToToolName(actorDefinitionPruned.actorFullName),
143139
actorFullName: actorDefinitionPruned.actorFullName,
144-
description: `${actorDefinitionPruned.description} Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`,
140+
description: `This tool calls the Actor "${actorDefinitionPruned.actorFullName}" and retrieves its output results. Use this tool instead of the "${HelperTools.ACTOR_CALL}" if user requests to use this specific Actor.
141+
Actor description: ${actorDefinitionPruned.description}
142+
Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`,
145143
inputSchema: actorDefinitionPruned.input
146144
// So Actor without input schema works - MCP client expects JSON schema valid output
147145
|| {
@@ -246,53 +244,84 @@ export async function getActorsAsTools(
246244

247245
const callActorArgs = z.object({
248246
actor: z.string()
249-
.describe('The name of the Actor to call. For example, "apify/instagram-scraper".'),
247+
.describe('The name of the Actor to call. For example, "apify/rag-web-browser".'),
248+
step: z.enum(['info', 'call'])
249+
.default('info')
250+
.describe(`Step to perform: "info" to get Actor details and input schema (required first step), "call" to execute the Actor (only after getting info).`),
250251
input: z.object({}).passthrough()
251-
.describe('The input JSON to pass to the Actor. For example, {"query": "apify", "maxItems": 10}.'),
252+
.optional()
253+
.describe(`The input JSON to pass to the Actor. For example, {"query": "apify", "maxResults": 5, "outputFormats": ["markdown"]}. Required only when step is "call".`),
252254
callOptions: z.object({
253-
memory: z.number().optional(),
254-
timeout: z.number().optional(),
255+
memory: z.number()
256+
.min(128, 'Memory must be at least 128 MB')
257+
.max(32768, 'Memory cannot exceed 32 GB (32768 MB)')
258+
.optional()
259+
.describe(`Memory allocation for the Actor in MB. Must be a power of 2 (e.g., 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768). Minimum: 128 MB, Maximum: 32768 MB (32 GB).`),
260+
timeout: z.number()
261+
.min(0, 'Timeout must be 0 or greater')
262+
.optional()
263+
.describe(`Maximum runtime for the Actor in seconds. After this time elapses, the Actor will be automatically terminated. Use 0 for infinite timeout (no time limit). Minimum: 0 seconds (infinite).`),
255264
}).optional()
256-
.describe('Optional call options for the Actor.'),
265+
.describe('Optional call options for the Actor run configuration.'),
257266
});
258267

259268
export const callActor: ToolEntry = {
260269
type: 'internal',
261270
tool: {
262271
name: HelperTools.ACTOR_CALL,
263272
actorFullName: HelperTools.ACTOR_CALL,
264-
description: `Call an Actor and get the Actor run results. If you are not sure about the Actor input, you MUST get the Actor details first, which also returns the input schema using ${HelperTools.ACTOR_GET_DETAILS}. The Actor MUST be added before calling; use the ${HelperTools.ACTOR_ADD} tool first. By default, the Apify MCP server makes newly added Actors available as tools for calling. Use this tool ONLY if you cannot call the newly added tool directly, and NEVER call this tool before first trying to call the tool directly. For example, when you add an Actor "apify/instagram-scraper" using the ${HelperTools.ACTOR_ADD} tool, the Apify MCP server will add a new tool ${actorNameToToolName('apify/instagram-scraper')} that you can call directly. If calling this tool does not work, then and ONLY then MAY you use this tool as a backup.`,
273+
description: `Call Any Actor from Apify Store - Two-Step Process
274+
275+
This tool uses a mandatory two-step process to safely call any Actor from the Apify store.
276+
277+
USAGE:
278+
• ONLY for Actors that are NOT available as dedicated tools
279+
• If a dedicated tool exists (e.g., ${actorNameToToolName('apify/rag-web-browser')}), use that instead
280+
281+
MANDATORY TWO-STEP WORKFLOW:
282+
283+
Step 1: Get Actor Info (step="info", default)
284+
• First call this tool with step="info" to get Actor details and input schema
285+
• This returns the Actor description, documentation, and required input schema
286+
• You MUST do this step first - it's required to understand how to call the Actor
287+
288+
Step 2: Call Actor (step="call")
289+
• Only after step 1, call again with step="call" and proper input based on the schema
290+
• This executes the Actor and returns the results
291+
292+
The step parameter enforces this workflow - you cannot call an Actor without first getting its info.`,
265293
inputSchema: zodToJsonSchema(callActorArgs),
266294
ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)),
267295
call: async (toolArgs) => {
268-
const { apifyMcpServer, args, apifyToken, progressTracker } = toolArgs;
269-
const { actor: actorName, input, callOptions } = callActorArgs.parse(args);
296+
const { args, apifyToken, progressTracker } = toolArgs;
297+
const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args);
270298

271-
const actors = apifyMcpServer.listActorToolNames();
272-
if (!actors.includes(actorName)) {
273-
const toolsText = actors.length > 0 ? `Available Actors are: ${actors.join(', ')}` : 'No Actors have been added yet.';
274-
if (apifyMcpServer.tools.has(HelperTools.ACTOR_ADD)) {
299+
try {
300+
if (step === 'info') {
301+
// Step 1: Return actor card and schema directly
302+
const details = await fetchActorDetails(apifyToken, actorName);
303+
if (!details) {
304+
return {
305+
content: [{ type: 'text', text: `Actor information for '${actorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }],
306+
};
307+
}
308+
return {
309+
content: [
310+
{ type: 'text', text: `**Actor card**:\n${details.actorCard}` },
311+
{ type: 'text', text: `**README:**\n${details.readme}` },
312+
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
313+
],
314+
};
315+
}
316+
// Step 2: Call the Actor
317+
if (!input) {
275318
return {
276-
content: [{
277-
type: 'text',
278-
text: `Actor '${actorName}' is not added. Add it with the '${HelperTools.ACTOR_ADD}' tool. ${toolsText}`,
279-
}],
319+
content: [
320+
{ type: 'text', text: `Input is required when step="call". Please provide the input parameter based on the Actor's input schema.` },
321+
],
280322
};
281323
}
282-
return {
283-
content: [{
284-
type: 'text',
285-
text: `Actor '${actorName}' is not added. ${toolsText}\n`
286-
+ 'To use this MCP server, specify the actors with the parameter, for example:\n'
287-
+ '?actors=apify/instagram-scraper,apify/website-content-crawler\n'
288-
+ 'or with the CLI:\n'
289-
+ '--actors "apify/instagram-scraper,apify/website-content-crawler"\n'
290-
+ 'You can only use actors that are included in the list; actors not in the list cannot be used.',
291-
}],
292-
};
293-
}
294324

295-
try {
296325
const [actor] = await getActorsAsTools([actorName], apifyToken);
297326

298327
if (!actor) {
@@ -315,25 +344,30 @@ export const callActor: ToolEntry = {
315344
}
316345
}
317346

318-
const { items } = await callActorGetDataset(
347+
const { runId, datasetId, items } = await callActorGetDataset(
319348
actorName,
320349
input,
321350
apifyToken,
322351
callOptions,
323352
progressTracker,
324353
);
325354

326-
return {
327-
content: items.items.map((item: Record<string, unknown>) => ({
328-
type: 'text',
329-
text: JSON.stringify(item),
330-
})),
331-
};
355+
const content = [
356+
{ type: 'text', text: `Actor finished with runId: ${runId}, datasetId ${datasetId}` },
357+
];
358+
359+
const itemContents = items.items.map((item: Record<string, unknown>) => ({
360+
type: 'text',
361+
text: JSON.stringify(item),
362+
}));
363+
content.push(...itemContents);
364+
365+
return { content };
332366
} catch (error) {
333-
log.error('Error calling Actor', { error });
367+
log.error('Error with Actor operation', { error, actorName, step });
334368
return {
335369
content: [
336-
{ type: 'text', text: `Error calling Actor: ${error instanceof Error ? error.message : String(error)}` },
370+
{ type: 'text', text: `Error with Actor operation: ${error instanceof Error ? error.message : String(error)}` },
337371
],
338372
};
339373
}

src/tools/fetch-actor-details.ts

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import type { Actor, Build } from 'apify-client';
21
import { z } from 'zod';
32
import zodToJsonSchema from 'zod-to-json-schema';
43

5-
import { ApifyClient } from '../apify-client.js';
64
import { HelperTools } from '../const.js';
7-
import type { IActorInputSchema, InternalTool, ToolEntry } from '../types.js';
8-
import { formatActorToActorCard } from '../utils/actor-card.js';
5+
import type { InternalTool, ToolEntry } from '../types.js';
6+
import { fetchActorDetails } from '../utils/actor-details.js';
97
import { ajv } from '../utils/ajv.js';
10-
import { filterSchemaProperties, shortenProperties } from './utils.js';
118

129
const fetchActorDetailsToolArgsSchema = z.object({
1310
actor: z.string()
@@ -32,36 +29,18 @@ export const fetchActorDetailsTool: ToolEntry = {
3229
ajvValidate: ajv.compile(zodToJsonSchema(fetchActorDetailsToolArgsSchema)),
3330
call: async (toolArgs) => {
3431
const { args, apifyToken } = toolArgs;
35-
3632
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
37-
const client = new ApifyClient({ token: apifyToken });
38-
39-
const [actorInfo, buildInfo]: [Actor | undefined, Build | undefined] = await Promise.all([
40-
client.actor(parsed.actor).get(),
41-
client.actor(parsed.actor).defaultBuild().then(async (build) => build.get()),
42-
]);
43-
44-
if (!actorInfo || !buildInfo || !buildInfo.actorDefinition) {
33+
const details = await fetchActorDetails(apifyToken, parsed.actor);
34+
if (!details) {
4535
return {
4636
content: [{ type: 'text', text: `Actor information for '${parsed.actor}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }],
4737
};
4838
}
49-
50-
const inputSchema = (buildInfo.actorDefinition.input || {
51-
type: 'object',
52-
properties: {},
53-
}) as IActorInputSchema;
54-
inputSchema.properties = filterSchemaProperties(inputSchema.properties);
55-
inputSchema.properties = shortenProperties(inputSchema.properties);
56-
57-
// Use the actor formatter to get the main actor details
58-
const actorCard = formatActorToActorCard(actorInfo);
59-
6039
return {
6140
content: [
62-
{ type: 'text', text: `**Actor card**:\n${actorCard}` },
63-
{ type: 'text', text: `**README:**\n${buildInfo.actorDefinition.readme || 'No README provided.'}` },
64-
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(inputSchema, null, 0)}` },
41+
{ type: 'text', text: `**Actor card**:\n${details.actorCard}` },
42+
{ type: 'text', text: `**README:**\n${details.readme}` },
43+
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
6544
],
6645
};
6746
},

src/utils/actor-details.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Actor, Build } from 'apify-client';
2+
3+
import { ApifyClient } from '../apify-client.js';
4+
import { filterSchemaProperties, shortenProperties } from '../tools/utils.js';
5+
import type { IActorInputSchema } from '../types.js';
6+
import { formatActorToActorCard } from './actor-card.js';
7+
8+
// Keep the interface here since it is a self contained module
9+
export interface ActorDetailsResult {
10+
actorInfo: Actor;
11+
buildInfo: Build;
12+
actorCard: string;
13+
inputSchema: IActorInputSchema;
14+
readme: string;
15+
}
16+
17+
export async function fetchActorDetails(apifyToken: string, actorName: string): Promise<ActorDetailsResult | null> {
18+
const client = new ApifyClient({ token: apifyToken });
19+
const [actorInfo, buildInfo]: [Actor | undefined, Build | undefined] = await Promise.all([
20+
client.actor(actorName).get(),
21+
client.actor(actorName).defaultBuild().then(async (build) => build.get()),
22+
]);
23+
if (!actorInfo || !buildInfo || !buildInfo.actorDefinition) return null;
24+
const inputSchema = (buildInfo.actorDefinition.input || {
25+
type: 'object',
26+
properties: {},
27+
}) as IActorInputSchema;
28+
inputSchema.properties = filterSchemaProperties(inputSchema.properties);
29+
inputSchema.properties = shortenProperties(inputSchema.properties);
30+
const actorCard = formatActorToActorCard(actorInfo);
31+
return {
32+
actorInfo,
33+
buildInfo,
34+
actorCard,
35+
inputSchema,
36+
readme: buildInfo.actorDefinition.readme || 'No README provided.',
37+
};
38+
}

tests/integration/suite.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export function createIntegrationTestsSuite(
229229
await client.close();
230230
});
231231

232-
it('should add Actor dynamically and call it via generic call-actor tool', async () => {
232+
it('should call Actor dynamically via generic call-actor tool without need to add it first', async () => {
233233
const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE);
234234
const client = await createClientFn({ enableAddingActors: true, tools: ['actors'] });
235235
const names = getToolNames(await client.listTools());
@@ -238,18 +238,12 @@ export function createIntegrationTestsSuite(
238238
expect(names).toHaveLength(numberOfTools);
239239
// Check that the Actor is not in the tools list
240240
expect(names).not.toContain(selectedToolName);
241-
// Add Actor dynamically
242-
await addActor(client, ACTOR_PYTHON_EXAMPLE);
243-
244-
// Check if tools was added
245-
const namesAfterAdd = getToolNames(await client.listTools());
246-
expect(namesAfterAdd).toHaveLength(numberOfTools + 1);
247-
expect(namesAfterAdd).toContain(selectedToolName);
248241

249242
const result = await client.callTool({
250243
name: HelperTools.ACTOR_CALL,
251244
arguments: {
252245
actor: ACTOR_PYTHON_EXAMPLE,
246+
step: 'call',
253247
input: {
254248
first_number: 1,
255249
second_number: 2,
@@ -260,6 +254,10 @@ export function createIntegrationTestsSuite(
260254
expect(result).toEqual(
261255
{
262256
content: [
257+
{
258+
text: expect.stringMatching(/^Actor finished with runId: .+, datasetId .+$/),
259+
type: 'text',
260+
},
263261
{
264262
text: `{"sum":3,"first_number":1,"second_number":2}`,
265263
type: 'text',

0 commit comments

Comments
 (0)