diff --git a/.gitignore b/.gitignore index 75950be..c32757f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/const.ts b/src/const.ts index 5d4575b..849ef66 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 */ diff --git a/src/state.ts b/src/state.ts index 4d30553..b8380d7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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'; @@ -15,3 +17,9 @@ export const searchApifyDocsCache = new TTLLRUCache(API export const fetchApifyDocsCache = new TTLLRUCache(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(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(MCP_SERVER_CACHE_MAX_SIZE, MCP_SERVER_CACHE_TTL_SECS); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 139ac55..200c354 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -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'; @@ -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), @@ -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 }; } /** @@ -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)}`, + ]); } } @@ -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)}`]); } }, }, diff --git a/src/utils/actor-response.ts b/src/utils/actor-response.ts index 5dc77b0..fe99865 100644 --- a/src/utils/actor-response.ts +++ b/src/utils/actor-response.ts @@ -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. diff --git a/src/utils/actor.ts b/src/utils/actor.ts index 9871d67..e9645a9 100644 --- a/src/utils/actor.ts +++ b/src/utils/actor.ts @@ -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 { + 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. diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 954bcec..5fad384 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -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'],