From 703db22ada2389d3b34e21bf812227b7d84782df Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Mon, 7 Jul 2025 11:32:44 +0200 Subject: [PATCH 1/7] feat: add call-actor tool --- src/const.ts | 1 + src/tools/actor.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 3 +- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/const.ts b/src/const.ts index 80cfc7a0..92b0f5b2 100644 --- a/src/const.ts +++ b/src/const.ts @@ -17,6 +17,7 @@ export const USER_AGENT_ORIGIN = 'Origin/mcp-server'; export enum HelperTools { ACTOR_ADD = 'add-actor', + ACTOR_CALL = 'call-actor', ACTOR_GET = 'get-actor', ACTOR_GET_DETAILS = 'get-actor-details', ACTOR_REMOVE = 'remove-actor', diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 998f546c..9648e1d4 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -261,3 +261,79 @@ export const getActor: ToolEntry = { }, } as InternalTool, }; + +const callActorArgs = z.object({ + actorName: z.string() + .describe('The name of the Actor to call.'), + input: z.any() + .describe('The input to pass to the Actor.'), + callOptions: z.object({ + memory: z.number().optional(), + timeout: z.number().optional(), + }).optional() + .describe('Optional call options for the Actor.'), +}); + +export const callActor: ToolEntry = { + type: 'internal', + tool: { + name: HelperTools.ACTOR_CALL, + actorFullName: HelperTools.ACTOR_CALL, + description: 'Call Actor and get dataset id. Call without input and result response with requred input properties.', + inputSchema: zodToJsonSchema(callActorArgs), + ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)), + call: async (toolArgs) => { + const { args, apifyToken } = toolArgs; + const { actorName, input, callOptions } = callActorArgs.parse(args); + if (!apifyToken) { + throw new Error('APIFY_TOKEN environment variable is not set.'); + } + try { + // FIXME: Bug, every call add "**REQUIRED**" to the description of the input properties + const [actor] = await getActorsAsTools([actorName], apifyToken); + + if (!actor) { + return { + content: [ + { type: 'text', text: `Actor '${actorName}' not found.` }, + ], + }; + } + + if (!actor.tool.ajvValidate(input)) { + const { errors } = actor.tool.ajvValidate; + if (errors && errors.length > 0) { + return { + content: [ + { type: 'text', text: `Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}` }, + { type: 'json', json: actor.tool.inputSchema }, + ], + }; + } + } + + const { actorRun, datasetInfo, items } = await callActorGetDataset( + actorName, + input, + apifyToken, + callOptions, + ); + + return { + content: [ + { type: 'text', text: `Actor run ID: ${actorRun.id}` }, + { type: 'text', text: `Dataset ID: ${datasetInfo?.id}` }, + { type: 'text', text: `Items count: ${items.total}` }, + ], + }; + } catch (error) { + console.error(`Error calling Actor: ${error}`); + return { + content: [ + { type: 'text', text: `Error calling Actor: ${error instanceof Error ? error.message : String(error)}` }, + ], + }; + } + }, + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 50bd2802..0f6b0b0d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,5 +1,5 @@ // Import specific tools that are being used -import { callActorGetDataset, getActor, getActorsAsTools } from './actor.js'; +import { callActor, callActorGetDataset, getActor, getActorsAsTools } from './actor.js'; import { actorDefinitionTool } from './build.js'; import { getDataset, getDatasetItems } from './dataset.js'; import { getUserDatasetsList } from './dataset_collection.js'; @@ -13,6 +13,7 @@ import { searchActors } from './store_collection.js'; export const defaultTools = [ abortActorRun, actorDefinitionTool, + callActor, getActor, getActorLog, getActorRun, From 65941a7ac0206e216a88fa562e62a61a478a1449 Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Mon, 7 Jul 2025 13:20:25 +0200 Subject: [PATCH 2/7] fix: call-actor input --- src/tools/actor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 9648e1d4..d08c38ce 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -265,8 +265,8 @@ export const getActor: ToolEntry = { const callActorArgs = z.object({ actorName: z.string() .describe('The name of the Actor to call.'), - input: z.any() - .describe('The input to pass to the Actor.'), + input: z.object({}).passthrough() + .describe('The input JSON to pass to the Actor.'), callOptions: z.object({ memory: z.number().optional(), timeout: z.number().optional(), From 5b7c8def80539efac4a10a2045ca3f72e66f4aa0 Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Mon, 7 Jul 2025 16:34:57 +0200 Subject: [PATCH 3/7] fix: immutable actor cache --- src/tools/actor.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index aff87228..4e209259 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -241,9 +241,11 @@ export async function getActorsAsTools( }), ); + const clonedActors = structuredClone(actorsInfo); + // Filter out nulls and separate Actors with MCP servers and normal Actors - const actorMCPServersInfo = actorsInfo.filter((actorInfo) => actorInfo && actorInfo.webServerMcpPath) as ActorInfo[]; - const normalActorsInfo = actorsInfo.filter((actorInfo) => actorInfo && !actorInfo.webServerMcpPath) as ActorInfo[]; + const actorMCPServersInfo = clonedActors.filter((actorInfo) => actorInfo && actorInfo.webServerMcpPath) as ActorInfo[]; + const normalActorsInfo = clonedActors.filter((actorInfo) => actorInfo && !actorInfo.webServerMcpPath) as ActorInfo[]; const [normalTools, mcpServerTools] = await Promise.all([ getNormalActorsAsTools(normalActorsInfo), @@ -315,7 +317,6 @@ export const callActor: ToolEntry = { throw new Error('APIFY_TOKEN environment variable is not set.'); } try { - // FIXME: Bug, every call add "**REQUIRED**" to the description of the input properties const [actor] = await getActorsAsTools([actorName], apifyToken); if (!actor) { @@ -338,7 +339,7 @@ export const callActor: ToolEntry = { } } - const { actorRun, datasetInfo, items } = await callActorGetDataset( + const { items } = await callActorGetDataset( actorName, input, apifyToken, @@ -346,11 +347,10 @@ export const callActor: ToolEntry = { ); return { - content: [ - { type: 'text', text: `Actor run ID: ${actorRun.id}` }, - { type: 'text', text: `Dataset ID: ${datasetInfo?.id}` }, - { type: 'text', text: `Items count: ${items.total}` }, - ], + content: items.items.map((item: Record) => ({ + type: 'text', + text: JSON.stringify(item), + })), }; } catch (error) { console.error(`Error calling Actor: ${error}`); From 823f0e3602f0642395203040a2cac3ab76ad323c Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Tue, 8 Jul 2025 10:41:31 +0200 Subject: [PATCH 4/7] feat: call-actor can be used only for added Actors --- src/mcp/server.ts | 2 +- src/tools/actor.ts | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 617962e9..d5b06470 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -130,7 +130,7 @@ export class ActorsMcpServer { * Returns the list of all currently loaded Actor tool IDs. * @returns {string[]} - Array of loaded Actor tool IDs (e.g., 'apify/rag-web-browser') */ - private listActorToolNames(): string[] { + public listActorToolNames(): string[] { return Array.from(this.tools.values()) .filter((tool) => tool.type === 'actor') .map((tool) => (tool.tool as ActorTool).actorFullName); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 4e209259..3b10d5d6 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -311,11 +311,28 @@ export const callActor: ToolEntry = { inputSchema: zodToJsonSchema(callActorArgs), ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { apifyMcpServer, args, apifyToken } = toolArgs; const { actorName, input, callOptions } = callActorArgs.parse(args); - if (!apifyToken) { - throw new Error('APIFY_TOKEN environment variable is not set.'); + + const actors = apifyMcpServer.listActorToolNames(); + if (!actors.includes(actorName)) { + const toolsText = actors.length > 0 ? `Added Actors are: ${actors.join(', ')}` : 'Not added Actors yet.'; + if (apifyMcpServer.tools.has(HelperTools.ACTOR_ADD)) { + return { + content: [{ + type: 'text', + text: `Actor '${actorName}' is not added. Add it with tool '${HelperTools.ACTOR_ADD}'. ${toolsText}`, + }], + }; + } + return { + content: [{ + type: 'text', + text: `Actor '${actorName}' is not added. ${toolsText}`, + }], + }; } + try { const [actor] = await getActorsAsTools([actorName], apifyToken); From f472b76550df7666fdb7d5bcc620056c98e84a51 Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Tue, 8 Jul 2025 10:56:37 +0200 Subject: [PATCH 5/7] feat: improve tools texts --- src/tools/actor.ts | 2 +- src/tools/helpers.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 3b10d5d6..463cae2e 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -307,7 +307,7 @@ export const callActor: ToolEntry = { tool: { name: HelperTools.ACTOR_CALL, actorFullName: HelperTools.ACTOR_CALL, - description: 'Call Actor and get dataset id. Call without input and result response with requred input properties.', + 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.`, inputSchema: zodToJsonSchema(callActorArgs), ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)), call: async (toolArgs) => { diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 7a2b412d..242e6806 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -39,7 +39,14 @@ export const addTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_ADD, - 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.`, + 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. +If added tools is not available, use generic tool ${HelperTools.ACTOR_CALL} to call added Actor directly.`, inputSchema: zodToJsonSchema(addToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool From 0d4ec50b03a78a89147dbde9d9271a26b781ec02 Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Tue, 8 Jul 2025 14:49:13 +0200 Subject: [PATCH 6/7] fix: code review improvements --- src/tools/actor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 463cae2e..7a630e55 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -316,7 +316,7 @@ export const callActor: ToolEntry = { const actors = apifyMcpServer.listActorToolNames(); if (!actors.includes(actorName)) { - const toolsText = actors.length > 0 ? `Added Actors are: ${actors.join(', ')}` : 'Not added Actors yet.'; + const toolsText = actors.length > 0 ? `Available Actors are: ${actors.join(', ')}` : 'Not added Actors yet.'; if (apifyMcpServer.tools.has(HelperTools.ACTOR_ADD)) { return { content: [{ @@ -328,7 +328,12 @@ export const callActor: ToolEntry = { return { content: [{ type: 'text', - text: `Actor '${actorName}' is not added. ${toolsText}`, + text: `Actor '${actorName}' is not added. ${toolsText} +To use this MCP server, specify the actors with the parameter, for example: +?actors=apify/instagram-scraper,apify/website-content-crawler +or with the CLI: +--actors "apify/instagram-scraper,apify/website-content-crawler" +You can only use actors that are included in the list; actors not in the list cannot be used.`, }], }; } @@ -370,7 +375,7 @@ export const callActor: ToolEntry = { })), }; } catch (error) { - console.error(`Error calling Actor: ${error}`); + log.error(`Error calling Actor: ${error}`); return { content: [ { type: 'text', text: `Error calling Actor: ${error instanceof Error ? error.message : String(error)}` }, From 3831e3e32aa33faad6d41b91833a7de2a5ef2d2b Mon Sep 17 00:00:00 2001 From: Michal Kalita Date: Tue, 8 Jul 2025 15:25:56 +0200 Subject: [PATCH 7/7] feat: add tests --- src/tools/actor.ts | 4 +- tests/integration/suite.ts | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 7a630e55..654f7f0b 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -291,7 +291,7 @@ export const getActor: ToolEntry = { }; const callActorArgs = z.object({ - actorName: z.string() + actor: z.string() .describe('The name of the Actor to call.'), input: z.object({}).passthrough() .describe('The input JSON to pass to the Actor.'), @@ -312,7 +312,7 @@ export const callActor: ToolEntry = { ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)), call: async (toolArgs) => { const { apifyMcpServer, args, apifyToken } = toolArgs; - const { actorName, input, callOptions } = callActorArgs.parse(args); + const { actor: actorName, input, callOptions } = callActorArgs.parse(args); const actors = apifyMcpServer.listActorToolNames(); if (!actors.includes(actorName)) { diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 905bf284..a5b69a8b 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -127,7 +127,7 @@ export function createIntegrationTestsSuite( await client.close(); }); - it('should add Actor dynamically and call it', async () => { + it('should add Actor dynamically and call it directly', async () => { const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE); const client = await createClientFn({ enableAddingActors: true }); const names = getToolNames(await client.listTools()); @@ -147,6 +147,82 @@ export function createIntegrationTestsSuite( await client.close(); }); + it('should add Actor dynamically and call it via generic call-actor tool', async () => { + const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE); + const client = await createClientFn({ enableAddingActors: true }); + const names = getToolNames(await client.listTools()); + const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; + expect(names.length).toEqual(numberOfTools); + // Check that the Actor is not in the tools list + expect(names).not.toContain(selectedToolName); + // Add Actor dynamically + await addActor(client, ACTOR_PYTHON_EXAMPLE); + + // Check if tools was added + const namesAfterAdd = getToolNames(await client.listTools()); + expect(namesAfterAdd.length).toEqual(numberOfTools + 1); + expect(namesAfterAdd).toContain(selectedToolName); + + const result = await client.callTool({ + name: HelperTools.ACTOR_CALL, + arguments: { + actor: ACTOR_PYTHON_EXAMPLE, + input: { + first_number: 1, + second_number: 2, + }, + }, + }); + + expect(result).toEqual( + { + content: [ + { + text: `{"sum":3,"first_number":1,"second_number":2}`, + type: 'text', + }, + ], + }, + ); + + await client.close(); + }); + + it('should not call Actor via call-actor tool if it is not added', async () => { + const selectedToolName = actorNameToToolName(ACTOR_PYTHON_EXAMPLE); + const client = await createClientFn({ enableAddingActors: true }); + const names = getToolNames(await client.listTools()); + const numberOfTools = defaultTools.length + addRemoveTools.length + defaults.actors.length; + expect(names.length).toEqual(numberOfTools); + // Check that the Actor is not in the tools list + expect(names).not.toContain(selectedToolName); + + const result = await client.callTool({ + name: HelperTools.ACTOR_CALL, + arguments: { + actor: ACTOR_PYTHON_EXAMPLE, + input: { + first_number: 1, + second_number: 2, + }, + }, + }); + + // TODO: make some more change-tolerant assertion, it's hard to verify text message result without exact match + expect(result).toEqual( + { + content: [ + { + text: "Actor 'apify/python-example' is not added. Add it with tool 'add-actor'. Available Actors are: apify/rag-web-browser", + type: 'text', + }, + ], + }, + ); + + await client.close(); + }); + // TODO: disabled for now, remove tools is disabled and might be removed in the future it.skip('should remove Actor from tools list', async () => { const actor = ACTOR_PYTHON_EXAMPLE;