Skip to content

Commit 7d00f9d

Browse files
authored
feat: call-actor tool (#161)
* feat: add call-actor tool * fix: call-actor input * fix: immutable actor cache * feat: call-actor can be used only for added Actors * feat: improve tools texts * fix: code review improvements * feat: add tests
1 parent 0f9a04d commit 7d00f9d

File tree

6 files changed

+189
-6
lines changed

6 files changed

+189
-6
lines changed

src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const USER_AGENT_ORIGIN = 'Origin/mcp-server';
1717

1818
export enum HelperTools {
1919
ACTOR_ADD = 'add-actor',
20+
ACTOR_CALL = 'call-actor',
2021
ACTOR_GET = 'get-actor',
2122
ACTOR_GET_DETAILS = 'get-actor-details',
2223
ACTOR_REMOVE = 'remove-actor',

src/mcp/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export class ActorsMcpServer {
130130
* Returns the list of all currently loaded Actor tool IDs.
131131
* @returns {string[]} - Array of loaded Actor tool IDs (e.g., 'apify/rag-web-browser')
132132
*/
133-
private listActorToolNames(): string[] {
133+
public listActorToolNames(): string[] {
134134
return Array.from(this.tools.values())
135135
.filter((tool) => tool.type === 'actor')
136136
.map((tool) => (tool.tool as ActorTool).actorFullName);

src/tools/actor.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,11 @@ export async function getActorsAsTools(
241241
}),
242242
);
243243

244+
const clonedActors = structuredClone(actorsInfo);
245+
244246
// Filter out nulls and separate Actors with MCP servers and normal Actors
245-
const actorMCPServersInfo = actorsInfo.filter((actorInfo) => actorInfo && actorInfo.webServerMcpPath) as ActorInfo[];
246-
const normalActorsInfo = actorsInfo.filter((actorInfo) => actorInfo && !actorInfo.webServerMcpPath) as ActorInfo[];
247+
const actorMCPServersInfo = clonedActors.filter((actorInfo) => actorInfo && actorInfo.webServerMcpPath) as ActorInfo[];
248+
const normalActorsInfo = clonedActors.filter((actorInfo) => actorInfo && !actorInfo.webServerMcpPath) as ActorInfo[];
247249

248250
const [normalTools, mcpServerTools] = await Promise.all([
249251
getNormalActorsAsTools(normalActorsInfo),
@@ -287,3 +289,99 @@ export const getActor: ToolEntry = {
287289
},
288290
} as InternalTool,
289291
};
292+
293+
const callActorArgs = z.object({
294+
actor: z.string()
295+
.describe('The name of the Actor to call.'),
296+
input: z.object({}).passthrough()
297+
.describe('The input JSON to pass to the Actor.'),
298+
callOptions: z.object({
299+
memory: z.number().optional(),
300+
timeout: z.number().optional(),
301+
}).optional()
302+
.describe('Optional call options for the Actor.'),
303+
});
304+
305+
export const callActor: ToolEntry = {
306+
type: 'internal',
307+
tool: {
308+
name: HelperTools.ACTOR_CALL,
309+
actorFullName: HelperTools.ACTOR_CALL,
310+
description: `Call Actor and get dataset results. Call without input and result response with requred input properties. Actor MUST be added before calling, use ${HelperTools.ACTOR_ADD} tool before.`,
311+
inputSchema: zodToJsonSchema(callActorArgs),
312+
ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)),
313+
call: async (toolArgs) => {
314+
const { apifyMcpServer, args, apifyToken } = toolArgs;
315+
const { actor: actorName, input, callOptions } = callActorArgs.parse(args);
316+
317+
const actors = apifyMcpServer.listActorToolNames();
318+
if (!actors.includes(actorName)) {
319+
const toolsText = actors.length > 0 ? `Available Actors are: ${actors.join(', ')}` : 'Not added Actors yet.';
320+
if (apifyMcpServer.tools.has(HelperTools.ACTOR_ADD)) {
321+
return {
322+
content: [{
323+
type: 'text',
324+
text: `Actor '${actorName}' is not added. Add it with tool '${HelperTools.ACTOR_ADD}'. ${toolsText}`,
325+
}],
326+
};
327+
}
328+
return {
329+
content: [{
330+
type: 'text',
331+
text: `Actor '${actorName}' is not added. ${toolsText}
332+
To use this MCP server, specify the actors with the parameter, for example:
333+
?actors=apify/instagram-scraper,apify/website-content-crawler
334+
or with the CLI:
335+
--actors "apify/instagram-scraper,apify/website-content-crawler"
336+
You can only use actors that are included in the list; actors not in the list cannot be used.`,
337+
}],
338+
};
339+
}
340+
341+
try {
342+
const [actor] = await getActorsAsTools([actorName], apifyToken);
343+
344+
if (!actor) {
345+
return {
346+
content: [
347+
{ type: 'text', text: `Actor '${actorName}' not found.` },
348+
],
349+
};
350+
}
351+
352+
if (!actor.tool.ajvValidate(input)) {
353+
const { errors } = actor.tool.ajvValidate;
354+
if (errors && errors.length > 0) {
355+
return {
356+
content: [
357+
{ type: 'text', text: `Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}` },
358+
{ type: 'json', json: actor.tool.inputSchema },
359+
],
360+
};
361+
}
362+
}
363+
364+
const { items } = await callActorGetDataset(
365+
actorName,
366+
input,
367+
apifyToken,
368+
callOptions,
369+
);
370+
371+
return {
372+
content: items.items.map((item: Record<string, unknown>) => ({
373+
type: 'text',
374+
text: JSON.stringify(item),
375+
})),
376+
};
377+
} catch (error) {
378+
log.error(`Error calling Actor: ${error}`);
379+
return {
380+
content: [
381+
{ type: 'text', text: `Error calling Actor: ${error instanceof Error ? error.message : String(error)}` },
382+
],
383+
};
384+
}
385+
},
386+
},
387+
};

src/tools/helpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ export const addTool: ToolEntry = {
3939
type: 'internal',
4040
tool: {
4141
name: HelperTools.ACTOR_ADD,
42-
description: `Add an Actor or MCP server to the available tools of the Apify MCP server. A tool is an Actor or MCP server that can be called by the user. Do not execute the tool, only add it and list it in the available tools. For example, when a user wants to scrape a website, first search for relevant Actors using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use, add it as a tool to the Apify MCP server.`,
42+
description:
43+
`Add an Actor or MCP server to the available tools of the Apify MCP server.
44+
A tool is an Actor or MCP server that can be called by the user.
45+
Do not execute the tool, only add it and list it in the available tools.
46+
For example, when a user wants to scrape a website, first search for relevant Actors
47+
using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use,
48+
add it as a tool to the Apify MCP server.
49+
If added tools is not available, use generic tool ${HelperTools.ACTOR_CALL} to call added Actor directly.`,
4350
inputSchema: zodToJsonSchema(addToolArgsSchema),
4451
ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)),
4552
// TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool

src/tools/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Import specific tools that are being used
2-
import { callActorGetDataset, getActorsAsTools } from './actor.js';
2+
import { callActor, callActorGetDataset, getActorsAsTools } from './actor.js';
33
import { getActorDetailsTool } from './get-actor-details.js';
44
import { addTool, helpTool } from './helpers.js';
55
import { searchActors } from './store_collection.js';
@@ -18,6 +18,7 @@ export const defaultTools = [
1818
// getUserRunsList,
1919
// getUserDatasetsList,
2020
// getUserKeyValueStoresList,
21+
callActor,
2122
getActorDetailsTool,
2223
helpTool,
2324
searchActors,

tests/integration/suite.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export function createIntegrationTestsSuite(
127127
await client.close();
128128
});
129129

130-
it('should add Actor dynamically and call it', async () => {
130+
it('should add Actor dynamically and call it directly', async () => {
131131
const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE);
132132
const client = await createClientFn({ enableAddingActors: true });
133133
const names = getToolNames(await client.listTools());
@@ -147,6 +147,82 @@ export function createIntegrationTestsSuite(
147147
await client.close();
148148
});
149149

150+
it('should add Actor dynamically and call it via generic call-actor tool', async () => {
151+
const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE);
152+
const client = await createClientFn({ enableAddingActors: true });
153+
const names = getToolNames(await client.listTools());
154+
const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length;
155+
expect(names.length).toEqual(numberOfTools);
156+
// Check that the Actor is not in the tools list
157+
expect(names).not.toContain(selectedToolName);
158+
// Add Actor dynamically
159+
await addActor(client, ACTOR_PYTHON_EXAMPLE);
160+
161+
// Check if tools was added
162+
const namesAfterAdd = getToolNames(await client.listTools());
163+
expect(namesAfterAdd.length).toEqual(numberOfTools + 1);
164+
expect(namesAfterAdd).toContain(selectedToolName);
165+
166+
const result = await client.callTool({
167+
name: HelperTools.ACTOR_CALL,
168+
arguments: {
169+
actor: ACTOR_PYTHON_EXAMPLE,
170+
input: {
171+
first_number: 1,
172+
second_number: 2,
173+
},
174+
},
175+
});
176+
177+
expect(result).toEqual(
178+
{
179+
content: [
180+
{
181+
text: `{"sum":3,"first_number":1,"second_number":2}`,
182+
type: 'text',
183+
},
184+
],
185+
},
186+
);
187+
188+
await client.close();
189+
});
190+
191+
it('should not call Actor via call-actor tool if it is not added', async () => {
192+
const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE);
193+
const client = await createClientFn({ enableAddingActors: true });
194+
const names = getToolNames(await client.listTools());
195+
const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length;
196+
expect(names.length).toEqual(numberOfTools);
197+
// Check that the Actor is not in the tools list
198+
expect(names).not.toContain(selectedToolName);
199+
200+
const result = await client.callTool({
201+
name: HelperTools.ACTOR_CALL,
202+
arguments: {
203+
actor: ACTOR_PYTHON_EXAMPLE,
204+
input: {
205+
first_number: 1,
206+
second_number: 2,
207+
},
208+
},
209+
});
210+
211+
// TODO: make some more change-tolerant assertion, it's hard to verify text message result without exact match
212+
expect(result).toEqual(
213+
{
214+
content: [
215+
{
216+
text: "Actor 'apify/python-example' is not added. Add it with tool 'add-actor'. Available Actors are: apify/rag-web-browser",
217+
type: 'text',
218+
},
219+
],
220+
},
221+
);
222+
223+
await client.close();
224+
});
225+
150226
// TODO: disabled for now, remove tools is disabled and might be removed in the future
151227
it.skip('should remove Actor from tools list', async () => {
152228
const actor = ACTOR_PYTHON_EXAMPLE;

0 commit comments

Comments
 (0)