Skip to content

Commit 84a8f8f

Browse files
authored
feat: call-actor add support for MCP server Actors (#274)
* refactor: move call-actor to a separate file and Actor related logic to utils * feat: call-actor add support for MCP server Actors * revert all the refactors
1 parent aecc147 commit 84a8f8f

File tree

7 files changed

+179
-47
lines changed

7 files changed

+179
-47
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ storage/key_value_stores/default/*
1818
# Added by Apify CLI
1919
.venv
2020
.env
21+
22+
# Aider coding agent files
2123
.aider*
24+
25+
# Ignore MCP config for Opencode client
26+
opencode.json

src/const.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export const APIFY_DOCS_CACHE_MAX_SIZE = 500;
7272
export const APIFY_DOCS_CACHE_TTL_SECS = 60 * 60; // 1 hour
7373
export const GET_HTML_SKELETON_CACHE_TTL_SECS = 5 * 60; // 5 minutes
7474
export const GET_HTML_SKELETON_CACHE_MAX_SIZE = 200;
75+
export const MCP_SERVER_CACHE_MAX_SIZE = 500;
76+
export const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes
7577

7678
export const ACTOR_PRICING_MODEL = {
7779
/** Rental Actors */

src/state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
APIFY_DOCS_CACHE_TTL_SECS,
66
GET_HTML_SKELETON_CACHE_MAX_SIZE,
77
GET_HTML_SKELETON_CACHE_TTL_SECS,
8+
MCP_SERVER_CACHE_MAX_SIZE,
9+
MCP_SERVER_CACHE_TTL_SECS,
810
} from './const.js';
911
import type { ActorDefinitionPruned, ApifyDocsSearchResult } from './types.js';
1012
import { TTLLRUCache } from './utils/ttl-lru.js';
@@ -15,3 +17,9 @@ export const searchApifyDocsCache = new TTLLRUCache<ApifyDocsSearchResult[]>(API
1517
export const fetchApifyDocsCache = new TTLLRUCache<string>(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS);
1618
/** Stores HTML content per URL so we can paginate the tool output */
1719
export const getHtmlSkeletonCache = new TTLLRUCache<string>(GET_HTML_SKELETON_CACHE_MAX_SIZE, GET_HTML_SKELETON_CACHE_TTL_SECS);
20+
/**
21+
* Stores MCP server resolution per actor:
22+
* - false: not an MCP server
23+
* - string: MCP server URL
24+
*/
25+
export const mcpServerCache = new TTLLRUCache<boolean | string>(MCP_SERVER_CACHE_MAX_SIZE, MCP_SERVER_CACHE_TTL_SECS);

src/tools/actor.ts

Lines changed: 100 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { connectMCPClient } from '../mcp/client.js';
1818
import { getMCPServerTools } from '../mcp/proxy.js';
1919
import { actorDefinitionPrunedCache } from '../state.js';
2020
import type { ActorDefinitionStorage, ActorInfo, ApifyToken, DatasetItem, ToolEntry } from '../types.js';
21-
import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames } from '../utils/actor.js';
21+
import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames, getActorMcpUrlCached } from '../utils/actor.js';
2222
import { fetchActorDetails } from '../utils/actor-details.js';
2323
import { buildActorResponseContent } from '../utils/actor-response.js';
2424
import { ajv } from '../utils/ajv.js';
25+
import { buildMCPResponse } from '../utils/mcp.js';
2526
import type { ProgressTracker } from '../utils/progress.js';
2627
import type { JsonSchemaProperty } from '../utils/schema-generation.js';
2728
import { generateSchemaFromItems } from '../utils/schema-generation.js';
@@ -329,12 +330,19 @@ MANDATORY TWO-STEP WORKFLOW:
329330
330331
Step 1: Get Actor Info (step="info", default)
331332
• First call this tool with step="info" to get Actor details and input schema
332-
• This returns the Actor description, documentation, and required input schema
333+
• For regular Actors: returns the Actor input schema
334+
• For MCP server Actors: returns list of available tools with their schemas
333335
• You MUST do this step first - it's required to understand how to call the Actor
334336
335-
Step 2: Call Actor (step="call")
337+
Step 2: Call Actor (step="call")
336338
• Only after step 1, call again with step="call" and proper input based on the schema
337-
• This executes the Actor and returns the results
339+
• For regular Actors: executes the Actor and returns results
340+
• For MCP server Actors: use format "actor-name:tool-name" to call specific tools
341+
342+
MCP SERVER ACTORS:
343+
• For MCP server actors, step="info" lists available tools instead of input schema
344+
• To call an MCP tool, use actor name format: "actor-name:tool-name" with step="call"
345+
• Example: actor="apify/my-mcp-actor:search-tool", step="call", input={...}
338346
339347
The step parameter enforces this workflow - you cannot call an Actor without first getting its info.`,
340348
inputSchema: zodToJsonSchema(callActorArgs),
@@ -347,29 +355,66 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
347355
const { args, apifyToken, progressTracker, extra, apifyMcpServer } = toolArgs;
348356
const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args);
349357

358+
// Parse special format: actor:tool
359+
const mcpToolMatch = actorName.match(/^(.+):(.+)$/);
360+
let baseActorName = actorName;
361+
let mcpToolName: string | undefined;
362+
363+
if (mcpToolMatch) {
364+
baseActorName = mcpToolMatch[1];
365+
mcpToolName = mcpToolMatch[2];
366+
}
367+
368+
// For definition resolution we always use token-based client; Skyfire is only for actual Actor runs
369+
const apifyClientForDefinition = new ApifyClient({ token: apifyToken });
370+
// Resolve MCP server URL
371+
const needsMcpUrl = mcpToolName !== undefined || step === 'info';
372+
const mcpServerUrlOrFalse = needsMcpUrl ? await getActorMcpUrlCached(baseActorName, apifyClientForDefinition) : false;
373+
const isActorMcpServer = mcpServerUrlOrFalse && typeof mcpServerUrlOrFalse === 'string';
374+
375+
// Standby Actors, thus MCPs, are not supported in Skyfire mode
376+
if (isActorMcpServer && apifyMcpServer.options.skyfireMode) {
377+
return buildMCPResponse([`MCP server Actors are not supported in Skyfire mode. Please use a regular Apify token without Skyfire.`]);
378+
}
379+
350380
try {
351381
if (step === 'info') {
352-
const apifyClient = new ApifyClient({ token: apifyToken });
353-
// Step 1: Return Actor card and schema directly
354-
const details = await fetchActorDetails(apifyClient, actorName);
355-
if (!details) {
356-
return {
357-
content: [{ type: 'text', text: `Actor information for '${actorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }],
358-
};
382+
if (isActorMcpServer) {
383+
// MCP server: list tools
384+
const mcpServerUrl = mcpServerUrlOrFalse;
385+
let client: Client | undefined;
386+
// Nested try to ensure client is closed
387+
try {
388+
client = await connectMCPClient(mcpServerUrl, apifyToken);
389+
const toolsResponse = await client.listTools();
390+
391+
const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput Schema: ${JSON.stringify(tool.inputSchema, null, 2)}`,
392+
).join('\n\n');
393+
394+
return buildMCPResponse([`This is an MCP Server Actor with the following tools:\n\n${toolsInfo}\n\nTo call a tool, use step="call" with actor name format: "${baseActorName}:{toolName}"`]);
395+
} finally {
396+
if (client) await client.close();
397+
}
398+
} else {
399+
// Regular actor: return schema
400+
const details = await fetchActorDetails(apifyClientForDefinition, baseActorName);
401+
if (!details) {
402+
return buildMCPResponse([`Actor information for '${baseActorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.`]);
403+
}
404+
const content = [
405+
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
406+
];
407+
/**
408+
* Add Skyfire instructions also in the info step since clients are most likely truncating the long tool description of the call-actor.
409+
*/
410+
if (apifyMcpServer.options.skyfireMode) {
411+
content.push({
412+
type: 'text',
413+
text: SKYFIRE_TOOL_INSTRUCTIONS,
414+
});
415+
}
416+
return { content };
359417
}
360-
const content = [
361-
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
362-
];
363-
/**
364-
* Add Skyfire instructions also in the info step since clients are most likely truncating the long tool description of the call-actor.
365-
*/
366-
if (apifyMcpServer.options.skyfireMode) {
367-
content.push({
368-
type: 'text',
369-
text: SKYFIRE_TOOL_INSTRUCTIONS,
370-
});
371-
}
372-
return { content };
373418
}
374419

375420
/**
@@ -396,32 +441,45 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
396441

397442
// Step 2: Call the Actor
398443
if (!input) {
399-
return {
400-
content: [
401-
{ type: 'text', text: `Input is required when step="call". Please provide the input parameter based on the Actor's input schema.` },
402-
],
403-
};
444+
return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`]);
404445
}
405446

447+
// Handle MCP tool calls
448+
if (mcpToolName) {
449+
if (!isActorMcpServer) {
450+
return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]);
451+
}
452+
453+
const mcpServerUrl = mcpServerUrlOrFalse;
454+
let client: Client | undefined;
455+
try {
456+
client = await connectMCPClient(mcpServerUrl, apifyToken);
457+
458+
const result = await client.callTool({
459+
name: mcpToolName,
460+
arguments: input,
461+
});
462+
463+
return { content: result.content };
464+
} finally {
465+
if (client) await client.close();
466+
}
467+
}
468+
469+
// Handle regular Actor calls
406470
const [actor] = await getActorsAsTools([actorName], apifyClient);
407471

408472
if (!actor) {
409-
return {
410-
content: [
411-
{ type: 'text', text: `Actor '${actorName}' not found.` },
412-
],
413-
};
473+
return buildMCPResponse([`Actor '${actorName}' was not found.`]);
414474
}
415475

416476
if (!actor.tool.ajvValidate(input)) {
417477
const { errors } = actor.tool.ajvValidate;
418478
if (errors && errors.length > 0) {
419-
return {
420-
content: [
421-
{ type: 'text', text: `Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}` },
422-
{ type: 'text', text: `Input Schema:\n${JSON.stringify(actor.tool.inputSchema)}` },
423-
],
424-
};
479+
return buildMCPResponse([
480+
`Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}`,
481+
`Input Schema:\n${JSON.stringify(actor.tool.inputSchema)}`,
482+
]);
425483
}
426484
}
427485

@@ -444,12 +502,8 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
444502

445503
return { content };
446504
} catch (error) {
447-
log.error('Error with Actor operation', { error, actorName, step });
448-
return {
449-
content: [
450-
{ type: 'text', text: `Error with Actor operation: ${error instanceof Error ? error.message : String(error)}` },
451-
],
452-
};
505+
log.error('Failed to call Actor', { error, actorName, step });
506+
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]);
453507
}
454508
},
455509
},

src/utils/actor-response.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CallActorGetDatasetResult } from '../tools/actor';
1+
import type { CallActorGetDatasetResult } from '../tools/actor.js';
22

33
/**
44
* Builds the response content for Actor tool calls.

src/utils/actor.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,37 @@
1+
import type { ApifyClient } from '../apify-client.js';
2+
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
3+
import { mcpServerCache } from '../state.js';
4+
import { getActorDefinition } from '../tools/build.js';
15
import type { ActorDefinitionStorage, DatasetItem } from '../types.js';
26
import { getValuesByDotKeys } from './generic.js';
37

8+
/**
9+
* Resolve and cache the MCP server URL for the given Actor.
10+
* - Returns a string URL when the Actor exposes an MCP server
11+
* - Returns false when the Actor is not an MCP server
12+
* Uses a TTL LRU cache to avoid repeated API calls.
13+
*/
14+
export async function getActorMcpUrlCached(
15+
actorIdOrName: string,
16+
apifyClient: ApifyClient,
17+
): Promise<string | false> {
18+
const cached = mcpServerCache.get(actorIdOrName);
19+
if (cached !== null && cached !== undefined) {
20+
return cached as string | false;
21+
}
22+
23+
const actorDefinitionPruned = await getActorDefinition(actorIdOrName, apifyClient);
24+
const mcpPath = actorDefinitionPruned && getActorMCPServerPath(actorDefinitionPruned);
25+
if (actorDefinitionPruned && mcpPath) {
26+
const url = await getActorMCPServerURL(actorDefinitionPruned.id, mcpPath);
27+
mcpServerCache.set(actorIdOrName, url);
28+
return url;
29+
}
30+
31+
mcpServerCache.set(actorIdOrName, false);
32+
return false;
33+
}
34+
435
/**
536
* Returns an array of all field names mentioned in the display.properties
637
* of all views in the given ActorDefinitionStorage object.

tests/integration/suite.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,38 @@ export function createIntegrationTestsSuite(
486486
expect(result.content).toBeDefined();
487487
});
488488

489+
it('should call MCP server Actor via call-actor and invoke fetch-apify-docs tool', async () => {
490+
client = await createClientFn({ tools: ['actors'] });
491+
492+
// Step 1: info - ensure the MCP server Actor lists tools including fetch-apify-docs
493+
const infoResult = await client.callTool({
494+
name: HelperTools.ACTOR_CALL,
495+
arguments: {
496+
actor: ACTOR_MCP_SERVER_ACTOR_NAME,
497+
step: 'info',
498+
},
499+
});
500+
501+
expect(infoResult.content).toBeDefined();
502+
const infoContent = infoResult.content as { text: string }[];
503+
expect(infoContent.some((item) => item.text.includes('fetch-apify-docs'))).toBe(true);
504+
505+
// Step 2: call - invoke the MCP tool fetch-apify-docs via actor:tool syntax
506+
const DOCS_URL = 'https://docs.apify.com';
507+
const callResult = await client.callTool({
508+
name: HelperTools.ACTOR_CALL,
509+
arguments: {
510+
actor: `${ACTOR_MCP_SERVER_ACTOR_NAME}:fetch-apify-docs`,
511+
step: 'call',
512+
input: { url: DOCS_URL },
513+
},
514+
});
515+
516+
expect(callResult.content).toBeDefined();
517+
const callContent = callResult.content as { text: string }[];
518+
expect(callContent.some((item) => item.text.includes(`Fetched content from ${DOCS_URL}`))).toBe(true);
519+
});
520+
489521
it('should search Apify documentation', async () => {
490522
client = await createClientFn({
491523
tools: ['docs'],

0 commit comments

Comments
 (0)