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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ storage/key_value_stores/default/*
# Added by Apify CLI
.venv
.env

# Aider coding agent files
.aider*

# Ignore MCP config for Opencode client
opencode.json
2 changes: 2 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const APIFY_DOCS_CACHE_MAX_SIZE = 500;
export const APIFY_DOCS_CACHE_TTL_SECS = 60 * 60; // 1 hour
export const GET_HTML_SKELETON_CACHE_TTL_SECS = 5 * 60; // 5 minutes
export const GET_HTML_SKELETON_CACHE_MAX_SIZE = 200;
export const MCP_SERVER_CACHE_MAX_SIZE = 500;
export const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes

export const ACTOR_PRICING_MODEL = {
/** Rental Actors */
Expand Down
8 changes: 8 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
APIFY_DOCS_CACHE_TTL_SECS,
GET_HTML_SKELETON_CACHE_MAX_SIZE,
GET_HTML_SKELETON_CACHE_TTL_SECS,
MCP_SERVER_CACHE_MAX_SIZE,
MCP_SERVER_CACHE_TTL_SECS,
} from './const.js';
import type { ActorDefinitionPruned, ApifyDocsSearchResult } from './types.js';
import { TTLLRUCache } from './utils/ttl-lru.js';
Expand All @@ -15,3 +17,9 @@ export const searchApifyDocsCache = new TTLLRUCache<ApifyDocsSearchResult[]>(API
export const fetchApifyDocsCache = new TTLLRUCache<string>(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS);
/** Stores HTML content per URL so we can paginate the tool output */
export const getHtmlSkeletonCache = new TTLLRUCache<string>(GET_HTML_SKELETON_CACHE_MAX_SIZE, GET_HTML_SKELETON_CACHE_TTL_SECS);
/**
* Stores MCP server resolution per actor:
* - false: not an MCP server
* - string: MCP server URL
*/
export const mcpServerCache = new TTLLRUCache<boolean | string>(MCP_SERVER_CACHE_MAX_SIZE, MCP_SERVER_CACHE_TTL_SECS);
146 changes: 100 additions & 46 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import { connectMCPClient } from '../mcp/client.js';
import { getMCPServerTools } from '../mcp/proxy.js';
import { actorDefinitionPrunedCache } from '../state.js';
import type { ActorDefinitionStorage, ActorInfo, ApifyToken, DatasetItem, ToolEntry } from '../types.js';
import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames } from '../utils/actor.js';
import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames, getActorMcpUrlCached } from '../utils/actor.js';
import { fetchActorDetails } from '../utils/actor-details.js';
import { buildActorResponseContent } from '../utils/actor-response.js';
import { ajv } from '../utils/ajv.js';
import { buildMCPResponse } from '../utils/mcp.js';
import type { ProgressTracker } from '../utils/progress.js';
import type { JsonSchemaProperty } from '../utils/schema-generation.js';
import { generateSchemaFromItems } from '../utils/schema-generation.js';
Expand Down Expand Up @@ -329,12 +330,19 @@ MANDATORY TWO-STEP WORKFLOW:

Step 1: Get Actor Info (step="info", default)
• First call this tool with step="info" to get Actor details and input schema
• This returns the Actor description, documentation, and required input schema
• For regular Actors: returns the Actor input schema
• For MCP server Actors: returns list of available tools with their schemas
• You MUST do this step first - it's required to understand how to call the Actor

Step 2: Call Actor (step="call")
Step 2: Call Actor (step="call")
• Only after step 1, call again with step="call" and proper input based on the schema
• This executes the Actor and returns the results
• For regular Actors: executes the Actor and returns results
• For MCP server Actors: use format "actor-name:tool-name" to call specific tools

MCP SERVER ACTORS:
• For MCP server actors, step="info" lists available tools instead of input schema
• To call an MCP tool, use actor name format: "actor-name:tool-name" with step="call"
• Example: actor="apify/my-mcp-actor:search-tool", step="call", input={...}

The step parameter enforces this workflow - you cannot call an Actor without first getting its info.`,
inputSchema: zodToJsonSchema(callActorArgs),
Expand All @@ -347,29 +355,66 @@ The step parameter enforces this workflow - you cannot call an Actor without fir
const { args, apifyToken, progressTracker, extra, apifyMcpServer } = toolArgs;
const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args);

// Parse special format: actor:tool
const mcpToolMatch = actorName.match(/^(.+):(.+)$/);
let baseActorName = actorName;
let mcpToolName: string | undefined;

if (mcpToolMatch) {
baseActorName = mcpToolMatch[1];
mcpToolName = mcpToolMatch[2];
}

// For definition resolution we always use token-based client; Skyfire is only for actual Actor runs
const apifyClientForDefinition = new ApifyClient({ token: apifyToken });
// Resolve MCP server URL
const needsMcpUrl = mcpToolName !== undefined || step === 'info';
const mcpServerUrlOrFalse = needsMcpUrl ? await getActorMcpUrlCached(baseActorName, apifyClientForDefinition) : false;
const isActorMcpServer = mcpServerUrlOrFalse && typeof mcpServerUrlOrFalse === 'string';

// Standby Actors, thus MCPs, are not supported in Skyfire mode
if (isActorMcpServer && apifyMcpServer.options.skyfireMode) {
return buildMCPResponse([`MCP server Actors are not supported in Skyfire mode. Please use a regular Apify token without Skyfire.`]);
}

try {
if (step === 'info') {
const apifyClient = new ApifyClient({ token: apifyToken });
// Step 1: Return Actor card and schema directly
const details = await fetchActorDetails(apifyClient, actorName);
if (!details) {
return {
content: [{ type: 'text', text: `Actor information for '${actorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }],
};
if (isActorMcpServer) {
// MCP server: list tools
const mcpServerUrl = mcpServerUrlOrFalse;
let client: Client | undefined;
// Nested try to ensure client is closed
try {
client = await connectMCPClient(mcpServerUrl, apifyToken);
const toolsResponse = await client.listTools();

const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput Schema: ${JSON.stringify(tool.inputSchema, null, 2)}`,
).join('\n\n');

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}"`]);
} finally {
if (client) await client.close();
}
} else {
// Regular actor: return schema
const details = await fetchActorDetails(apifyClientForDefinition, baseActorName);
if (!details) {
return buildMCPResponse([`Actor information for '${baseActorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.`]);
}
const content = [
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
];
/**
* Add Skyfire instructions also in the info step since clients are most likely truncating the long tool description of the call-actor.
*/
if (apifyMcpServer.options.skyfireMode) {
content.push({
type: 'text',
text: SKYFIRE_TOOL_INSTRUCTIONS,
});
}
return { content };
}
const content = [
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` },
];
/**
* Add Skyfire instructions also in the info step since clients are most likely truncating the long tool description of the call-actor.
*/
if (apifyMcpServer.options.skyfireMode) {
content.push({
type: 'text',
text: SKYFIRE_TOOL_INSTRUCTIONS,
});
}
return { content };
}

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

// Step 2: Call the Actor
if (!input) {
return {
content: [
{ type: 'text', text: `Input is required when step="call". Please provide the input parameter based on the Actor's input schema.` },
],
};
return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`]);
}

// Handle MCP tool calls
if (mcpToolName) {
if (!isActorMcpServer) {
return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]);
}

const mcpServerUrl = mcpServerUrlOrFalse;
let client: Client | undefined;
try {
client = await connectMCPClient(mcpServerUrl, apifyToken);

const result = await client.callTool({
name: mcpToolName,
arguments: input,
});

return { content: result.content };
} finally {
if (client) await client.close();
}
}

// Handle regular Actor calls
const [actor] = await getActorsAsTools([actorName], apifyClient);

if (!actor) {
return {
content: [
{ type: 'text', text: `Actor '${actorName}' not found.` },
],
};
return buildMCPResponse([`Actor '${actorName}' was 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: 'text', text: `Input Schema:\n${JSON.stringify(actor.tool.inputSchema)}` },
],
};
return buildMCPResponse([
`Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}`,
`Input Schema:\n${JSON.stringify(actor.tool.inputSchema)}`,
]);
}
}

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

return { content };
} catch (error) {
log.error('Error with Actor operation', { error, actorName, step });
return {
content: [
{ type: 'text', text: `Error with Actor operation: ${error instanceof Error ? error.message : String(error)}` },
],
};
log.error('Failed to call Actor', { error, actorName, step });
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]);
}
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/utils/actor-response.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CallActorGetDatasetResult } from '../tools/actor';
import type { CallActorGetDatasetResult } from '../tools/actor.js';

/**
* Builds the response content for Actor tool calls.
Expand Down
31 changes: 31 additions & 0 deletions src/utils/actor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
import type { ApifyClient } from '../apify-client.js';
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
import { mcpServerCache } from '../state.js';
import { getActorDefinition } from '../tools/build.js';
import type { ActorDefinitionStorage, DatasetItem } from '../types.js';
import { getValuesByDotKeys } from './generic.js';

/**
* Resolve and cache the MCP server URL for the given Actor.
* - Returns a string URL when the Actor exposes an MCP server
* - Returns false when the Actor is not an MCP server
* Uses a TTL LRU cache to avoid repeated API calls.
*/
export async function getActorMcpUrlCached(
actorIdOrName: string,
apifyClient: ApifyClient,
): Promise<string | false> {
const cached = mcpServerCache.get(actorIdOrName);
if (cached !== null && cached !== undefined) {
return cached as string | false;
}

const actorDefinitionPruned = await getActorDefinition(actorIdOrName, apifyClient);
const mcpPath = actorDefinitionPruned && getActorMCPServerPath(actorDefinitionPruned);
if (actorDefinitionPruned && mcpPath) {
const url = await getActorMCPServerURL(actorDefinitionPruned.id, mcpPath);
mcpServerCache.set(actorIdOrName, url);
return url;
}

mcpServerCache.set(actorIdOrName, false);
return false;
}

/**
* Returns an array of all field names mentioned in the display.properties
* of all views in the given ActorDefinitionStorage object.
Expand Down
32 changes: 32 additions & 0 deletions tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,38 @@ export function createIntegrationTestsSuite(
expect(result.content).toBeDefined();
});

it('should call MCP server Actor via call-actor and invoke fetch-apify-docs tool', async () => {
client = await createClientFn({ tools: ['actors'] });

// Step 1: info - ensure the MCP server Actor lists tools including fetch-apify-docs
const infoResult = await client.callTool({
name: HelperTools.ACTOR_CALL,
arguments: {
actor: ACTOR_MCP_SERVER_ACTOR_NAME,
step: 'info',
},
});

expect(infoResult.content).toBeDefined();
const infoContent = infoResult.content as { text: string }[];
expect(infoContent.some((item) => item.text.includes('fetch-apify-docs'))).toBe(true);

// Step 2: call - invoke the MCP tool fetch-apify-docs via actor:tool syntax
const DOCS_URL = 'https://docs.apify.com';
const callResult = await client.callTool({
name: HelperTools.ACTOR_CALL,
arguments: {
actor: `${ACTOR_MCP_SERVER_ACTOR_NAME}:fetch-apify-docs`,
step: 'call',
input: { url: DOCS_URL },
},
});

expect(callResult.content).toBeDefined();
const callContent = callResult.content as { text: string }[];
expect(callContent.some((item) => item.text.includes(`Fetched content from ${DOCS_URL}`))).toBe(true);
});

it('should search Apify documentation', async () => {
client = await createClientFn({
tools: ['docs'],
Expand Down