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 src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
102 changes: 100 additions & 2 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -287,3 +289,99 @@ export const getActor: ToolEntry = {
},
} as InternalTool,
};

const callActorArgs = z.object({
actor: z.string()
.describe('The name of the Actor to call.'),
input: z.object({}).passthrough()
.describe('The input JSON 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 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) => {
const { apifyMcpServer, args, apifyToken } = toolArgs;
const { actor: actorName, input, callOptions } = callActorArgs.parse(args);

const actors = apifyMcpServer.listActorToolNames();
if (!actors.includes(actorName)) {
const toolsText = actors.length > 0 ? `Available 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}
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.`,
}],
};
}

try {
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 { items } = await callActorGetDataset(
actorName,
input,
apifyToken,
callOptions,
);

return {
content: items.items.map((item: Record<string, unknown>) => ({
type: 'text',
text: JSON.stringify(item),
})),
};
} catch (error) {
log.error(`Error calling Actor: ${error}`);
return {
content: [
{ type: 'text', text: `Error calling Actor: ${error instanceof Error ? error.message : String(error)}` },
],
};
}
},
},
};
9 changes: 8 additions & 1 deletion src/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Import specific tools that are being used
import { callActorGetDataset, getActorsAsTools } from './actor.js';
import { callActor, callActorGetDataset, getActorsAsTools } from './actor.js';
import { getActorDetailsTool } from './get-actor-details.js';
import { addTool, helpTool } from './helpers.js';
import { searchActors } from './store_collection.js';
Expand All @@ -18,6 +18,7 @@ export const defaultTools = [
// getUserRunsList,
// getUserDatasetsList,
// getUserKeyValueStoresList,
callActor,
getActorDetailsTool,
helpTool,
searchActors,
Expand Down
78 changes: 77 additions & 1 deletion tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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;
Expand Down