diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 5a94d6a9..5dc23e3d 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -17,6 +17,7 @@ export async function getMCPServerTools( const compiledTools: ToolEntry[] = []; for (const tool of tools) { const mcpTool: ActorMcpTool = { + type: 'actor-mcp', actorId: actorID, serverId: getMCPServerID(serverUrl), serverUrl, @@ -28,12 +29,7 @@ export async function getMCPServerTools( ajvValidate: fixedAjvCompile(ajv, tool.inputSchema), }; - const wrap: ToolEntry = { - type: 'actor-mcp', - tool: mcpTool, - }; - - compiledTools.push(wrap); + compiledTools.push(mcpTool); } return compiledTools; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6b966d72..6ff0a27f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -37,7 +37,7 @@ import { import { prompts } from '../prompts/index.js'; import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js'; import { decodeDotPropertyNames } from '../tools/utils.js'; -import type { ActorMcpTool, ActorTool, HelperTool, ToolEntry } from '../types.js'; +import type { ToolEntry } from '../types.js'; import { buildActorResponseContent } from '../utils/actor-response.js'; import { buildMCPResponse } from '../utils/mcp.js'; import { createProgressTracker } from '../utils/progress.js'; @@ -142,7 +142,7 @@ export class ActorsMcpServer { private listInternalToolNames(): string[] { return Array.from(this.tools.values()) .filter((tool) => tool.type === 'internal') - .map((tool) => (tool.tool as HelperTool).name); + .map((tool) => tool.name); } /** @@ -152,7 +152,7 @@ export class ActorsMcpServer { public listActorToolNames(): string[] { return Array.from(this.tools.values()) .filter((tool) => tool.type === 'actor') - .map((tool) => (tool.tool as ActorTool).actorFullName); + .map((tool) => tool.actorFullName); } /** @@ -162,7 +162,7 @@ export class ActorsMcpServer { private listActorMcpServerToolIds(): string[] { const ids = Array.from(this.tools.values()) .filter((tool: ToolEntry) => tool.type === 'actor-mcp') - .map((tool: ToolEntry) => (tool.tool as ActorMcpTool).actorId); + .map((tool) => tool.actorId); // Ensure uniqueness return Array.from(new Set(ids)); } @@ -188,7 +188,7 @@ export class ActorsMcpServer { const internalToolMap = new Map([ ...defaultTools, ...Object.values(toolCategories).flat(), - ].map((tool) => [tool.tool.name, tool])); + ].map((tool) => [tool.name, tool])); for (const tool of toolNames) { // Skip if the tool is already loaded @@ -266,18 +266,20 @@ export class ActorsMcpServer { if (this.options.skyfireMode) { for (const wrap of tools) { if (wrap.type === 'actor' - || (wrap.type === 'internal' && wrap.tool.name === HelperTools.ACTOR_CALL) - || (wrap.type === 'internal' && wrap.tool.name === HelperTools.ACTOR_OUTPUT_GET)) { + || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_CALL) + || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_OUTPUT_GET)) { // Clone the tool before modifying it to avoid affecting shared objects const clonedWrap = cloneToolEntry(wrap); // Add Skyfire instructions to description if not already present - if (!clonedWrap.tool.description.includes(SKYFIRE_TOOL_INSTRUCTIONS)) { - clonedWrap.tool.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`; + if (clonedWrap.description && !clonedWrap.description.includes(SKYFIRE_TOOL_INSTRUCTIONS)) { + clonedWrap.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`; + } else if (!clonedWrap.description) { + clonedWrap.description = SKYFIRE_TOOL_INSTRUCTIONS; } // Add skyfire-pay-id property if not present - if (clonedWrap.tool.inputSchema && 'properties' in clonedWrap.tool.inputSchema) { - const props = clonedWrap.tool.inputSchema.properties as Record; + if (clonedWrap.inputSchema && 'properties' in clonedWrap.inputSchema) { + const props = clonedWrap.inputSchema.properties as Record; if (!props['skyfire-pay-id']) { props['skyfire-pay-id'] = { type: 'string', @@ -287,16 +289,16 @@ export class ActorsMcpServer { } // Store the cloned and modified tool - this.tools.set(clonedWrap.tool.name, clonedWrap); + this.tools.set(clonedWrap.name, clonedWrap); } else { // Store unmodified tools as-is - this.tools.set(wrap.tool.name, wrap); + this.tools.set(wrap.name, wrap); } } } else { // No skyfire mode - store tools as-is for (const wrap of tools) { - this.tools.set(wrap.tool.name, wrap); + this.tools.set(wrap.name, wrap); } } if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler(); @@ -456,7 +458,7 @@ export class ActorsMcpServer { * @returns {object} - The response object containing the tools. */ this.server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool)); + const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool)); return { tools }; }); @@ -502,7 +504,7 @@ export class ActorsMcpServer { // TODO - if connection is /mcp client will not receive notification on tool change // Find tool by name or actor full name const tool = Array.from(this.tools.values()) - .find((t) => t.tool.name === name || (t.type === 'actor' && (t.tool as ActorTool).actorFullName === name)); + .find((t) => t.name === name || (t.type === 'actor' && t.actorFullName === name)); if (!tool) { const msg = `Tool ${name} not found. Available tools: ${this.listToolNames().join(', ')}`; log.error(msg); @@ -524,9 +526,9 @@ export class ActorsMcpServer { // Decode dot property names in arguments before validation, // since validation expects the original, non-encoded property names. args = decodeDotPropertyNames(args); - log.debug('Validate arguments for tool', { toolName: tool.tool.name, input: args }); - if (!tool.tool.ajvValidate(args)) { - const msg = `Invalid arguments for tool ${tool.tool.name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(tool?.tool.ajvValidate.errors)}`; + log.debug('Validate arguments for tool', { toolName: tool.name, input: args }); + if (!tool.ajvValidate(args)) { + const msg = `Invalid arguments for tool ${tool.name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(tool?.ajvValidate.errors)}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -538,15 +540,13 @@ export class ActorsMcpServer { try { // Handle internal tool if (tool.type === 'internal') { - const internalTool = tool.tool as HelperTool; - // Only create progress tracker for call-actor tool - const progressTracker = internalTool.name === 'call-actor' + const progressTracker = tool.name === 'call-actor' ? createProgressTracker(progressToken, extra.sendNotification) : null; - log.info('Calling internal tool', { name: internalTool.name, input: args }); - const res = await internalTool.call({ + log.info('Calling internal tool', { name: tool.name, input: args }); + const res = await tool.call({ args, extra, apifyMcpServer: this, @@ -564,12 +564,11 @@ export class ActorsMcpServer { } if (tool.type === 'actor-mcp') { - const serverTool = tool.tool as ActorMcpTool; let client: Client | null = null; try { - client = await connectMCPClient(serverTool.serverUrl, apifyToken); + client = await connectMCPClient(tool.serverUrl, apifyToken); if (!client) { - const msg = `Failed to connect to MCP server ${serverTool.serverUrl}`; + const msg = `Failed to connect to MCP server ${tool.serverUrl}`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); return { @@ -595,9 +594,9 @@ export class ActorsMcpServer { } } - log.info('Calling Actor-MCP', { actorId: serverTool.actorId, toolName: serverTool.originToolName, input: args }); + log.info('Calling Actor-MCP', { actorId: tool.actorId, toolName: tool.originToolName, input: args }); const res = await client.callTool({ - name: serverTool.originToolName, + name: tool.originToolName, arguments: args, _meta: { progressToken, @@ -625,12 +624,10 @@ export class ActorsMcpServer { }; } - const actorTool = tool.tool as ActorTool; - // Create progress tracker if progressToken is available const progressTracker = createProgressTracker(progressToken, extra.sendNotification); - const callOptions: ActorCallOptions = { memory: actorTool.memoryMbytes }; + const callOptions: ActorCallOptions = { memory: tool.memoryMbytes }; /** * Create Apify token, for Skyfire mode use `skyfire-pay-id` and for normal mode use `apifyToken`. @@ -641,9 +638,9 @@ export class ActorsMcpServer { : new ApifyClient({ token: apifyToken }); try { - log.info('Calling Actor', { actorName: actorTool.actorFullName, input: actorArgs }); + log.info('Calling Actor', { actorName: tool.actorFullName, input: actorArgs }); const callResult = await callActorGetDataset( - actorTool.actorFullName, + tool.actorFullName, actorArgs, apifyClient, callOptions, @@ -657,7 +654,7 @@ export class ActorsMcpServer { return { }; } - const content = buildActorResponseContent(actorTool.actorFullName, callResult); + const content = buildActorResponseContent(tool.actorFullName, callResult); return { content }; } finally { if (progressTracker) { @@ -698,8 +695,8 @@ export class ActorsMcpServer { } // Clear all tools and their compiled schemas for (const tool of this.tools.values()) { - if (tool.tool.ajvValidate && typeof tool.tool.ajvValidate === 'function') { - (tool.tool as { ajvValidate: ValidateFunction | null }).ajvValidate = null; + if (tool.ajvValidate && typeof tool.ajvValidate === 'function') { + (tool as { ajvValidate: ValidateFunction | null }).ajvValidate = null; } } this.tools.clear(); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 1732673b..b0439d13 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -19,7 +19,7 @@ import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js'; 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 type { ActorDefinitionStorage, ActorInfo, ApifyToken, DatasetItem, InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames, getActorMcpUrlCached } from '../utils/actor.js'; import { fetchActorDetails } from '../utils/actor-details.js'; import { buildActorResponseContent } from '../utils/actor-response.js'; @@ -192,14 +192,12 @@ Actor description: ${actorDefinitionPruned.description}`; tools.push({ type: 'actor', - tool: { - name: actorNameToToolName(actorDefinitionPruned.actorFullName), - actorFullName: actorDefinitionPruned.actorFullName, - description, - inputSchema, - ajvValidate, - memoryMbytes, - }, + name: actorNameToToolName(actorDefinitionPruned.actorFullName), + actorFullName: actorDefinitionPruned.actorFullName, + description, + inputSchema: inputSchema as ToolInputSchema, + ajvValidate, + memoryMbytes, }); } return tools; @@ -327,10 +325,8 @@ const callActorArgs = z.object({ export const callActor: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_CALL, - actorFullName: HelperTools.ACTOR_CALL, - description: `Call any Actor from the Apify Store using a mandatory two-step workflow. + name: HelperTools.ACTOR_CALL, + description: `Call any Actor from the Apify Store using a mandatory two-step workflow. This ensures you first get the Actor’s input schema and details before executing it safely. There are two ways to run Actors: @@ -359,181 +355,180 @@ Step 2: Call Actor (step="call") EXAMPLES: - user_input: Get instagram posts using apify/instagram-scraper`, - inputSchema: zodToJsonSchema(callActorArgs), - ajvValidate: ajv.compile({ - ...zodToJsonSchema(callActorArgs), - // Additional props true to allow skyfire-pay-id - additionalProperties: true, - }), - call: async (toolArgs) => { - const { args, apifyToken, progressTracker, extra, apifyMcpServer } = toolArgs; - const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args); - - // If input is provided but step is not "call", we assume the user wants to call the Actor - const performStep = input && step !== 'call' ? 'call' : step; + inputSchema: zodToJsonSchema(callActorArgs) as ToolInputSchema, + ajvValidate: ajv.compile({ + ...zodToJsonSchema(callActorArgs), + // Additional props true to allow skyfire-pay-id + additionalProperties: true, + }), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, progressTracker, extra, apifyMcpServer } = toolArgs; + const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args); + + // If input is provided but step is not "call", we assume the user wants to call the Actor + const performStep = input && step !== 'call' ? 'call' : step; + + // Parse special format: actor:tool + const mcpToolMatch = actorName.match(/^(.+):(.+)$/); + let baseActorName = actorName; + let mcpToolName: string | undefined; + + if (mcpToolMatch) { + baseActorName = mcpToolMatch[1]; + mcpToolName = mcpToolMatch[2]; + } - // Parse special format: actor:tool - const mcpToolMatch = actorName.match(/^(.+):(.+)$/); - let baseActorName = actorName; - let mcpToolName: string | undefined; + // 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 mcpServerUrlOrFalse = await getActorMcpUrlCached(baseActorName, apifyClientForDefinition); + const isActorMcpServer = mcpServerUrlOrFalse && typeof mcpServerUrlOrFalse === 'string'; - if (mcpToolMatch) { - baseActorName = mcpToolMatch[1]; - mcpToolName = mcpToolMatch[2]; - } + // 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.`]); + } - // 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 mcpServerUrlOrFalse = await getActorMcpUrlCached(baseActorName, apifyClientForDefinition); - const isActorMcpServer = mcpServerUrlOrFalse && typeof mcpServerUrlOrFalse === 'string'; + try { + if (performStep === 'info') { + if (isActorMcpServer) { + // MCP server: list tools + const mcpServerUrl = mcpServerUrlOrFalse; + let client: Client | null = null; + // Nested try to ensure client is closed + try { + client = await connectMCPClient(mcpServerUrl, apifyToken); + if (!client) { + return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); + } + const toolsResponse = await client.listTools(); - // 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.`]); - } + const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput schema:\n\`\`\`json\n${JSON.stringify(tool.inputSchema)}\n\`\`\``, + ).join('\n\n'); - try { - if (performStep === 'info') { - if (isActorMcpServer) { - // MCP server: list tools - const mcpServerUrl = mcpServerUrlOrFalse; - let client: Client | null = null; - // Nested try to ensure client is closed - try { - client = await connectMCPClient(mcpServerUrl, apifyToken); - if (!client) { - return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); - } - const toolsResponse = await client.listTools(); - - const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput schema:\n\`\`\`json\n${JSON.stringify(tool.inputSchema)}\n\`\`\``, - ).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 = [ - `Actor name: ${actorName}`, - `Input schema:\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\``, - `To run Actor, use step="call" with Actor name format: "${actorName}"`, - ]; + 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 = [ + `Actor name: ${actorName}`, + `Input schema:\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\``, + `To run Actor, use step="call" with Actor name format: "${actorName}"`, + ]; // Add Skyfire instructions also in the info performStep since clients are most likely truncating // the long tool description of the call-actor. - if (apifyMcpServer.options.skyfireMode) { - content.push(SKYFIRE_TOOL_INSTRUCTIONS); - } - return buildMCPResponse(content); + if (apifyMcpServer.options.skyfireMode) { + content.push(SKYFIRE_TOOL_INSTRUCTIONS); } + return buildMCPResponse(content); } + } - /** + /** * In Skyfire mode, we check for the presence of `skyfire-pay-id`. * If it is missing, we return instructions to the LLM on how to create it and pass it to the tool. */ - if (apifyMcpServer.options.skyfireMode + if (apifyMcpServer.options.skyfireMode && args['skyfire-pay-id'] === undefined - ) { - return { - content: [{ - type: 'text', - text: SKYFIRE_TOOL_INSTRUCTIONS, - }], - }; - } + ) { + return { + content: [{ + type: 'text', + text: SKYFIRE_TOOL_INSTRUCTIONS, + }], + }; + } - /** + /** * Create Apify token, for Skyfire mode use `skyfire-pay-id` and for normal mode use `apifyToken`. */ - const apifyClient = apifyMcpServer.options.skyfireMode && typeof args['skyfire-pay-id'] === 'string' - ? new ApifyClient({ skyfirePayId: args['skyfire-pay-id'] }) - : new ApifyClient({ token: apifyToken }); + const apifyClient = apifyMcpServer.options.skyfireMode && typeof args['skyfire-pay-id'] === 'string' + ? new ApifyClient({ skyfirePayId: args['skyfire-pay-id'] }) + : new ApifyClient({ token: apifyToken }); - // Step 2: Call the Actor - if (!input) { - return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`]); - } + // Step 2: Call the Actor + if (!input) { + return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`]); + } - // Handle the case where LLM does not respect instructions when calling MCP server Actors - // and does not provide the tool name. - const isMcpToolNameInvalid = mcpToolName === undefined || mcpToolName.trim().length === 0; - if (isActorMcpServer && isMcpToolNameInvalid) { - return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG]); + // Handle the case where LLM does not respect instructions when calling MCP server Actors + // and does not provide the tool name. + const isMcpToolNameInvalid = mcpToolName === undefined || mcpToolName.trim().length === 0; + if (isActorMcpServer && isMcpToolNameInvalid) { + return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG]); + } + + // Handle MCP tool calls + if (mcpToolName) { + if (!isActorMcpServer) { + return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]); } - // Handle MCP tool calls - if (mcpToolName) { - if (!isActorMcpServer) { - return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]); + const mcpServerUrl = mcpServerUrlOrFalse; + let client: Client | null = null; + try { + client = await connectMCPClient(mcpServerUrl, apifyToken); + if (!client) { + return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); } - const mcpServerUrl = mcpServerUrlOrFalse; - let client: Client | null = null; - try { - client = await connectMCPClient(mcpServerUrl, apifyToken); - if (!client) { - return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); - } - - const result = await client.callTool({ - name: mcpToolName, - arguments: input, - }); + const result = await client.callTool({ + name: mcpToolName, + arguments: input, + }); - return { content: result.content }; - } finally { - if (client) await client.close(); - } + return { content: result.content }; + } finally { + if (client) await client.close(); } + } - // Handle regular Actor calls - const [actor] = await getActorsAsTools([actorName], apifyClient); + // Handle regular Actor calls + const [actor] = await getActorsAsTools([actorName], apifyClient); - if (!actor) { - return buildMCPResponse([`Actor '${actorName}' was not found.`]); - } + if (!actor) { + return buildMCPResponse([`Actor '${actorName}' was not found.`]); + } - if (!actor.tool.ajvValidate(input)) { - const { errors } = actor.tool.ajvValidate; - const content = [ - `Input validation failed for Actor '${actorName}'. Please ensure your input matches the Actor's input schema.`, - `Input schema:\n\`\`\`json\n${JSON.stringify(actor.tool.inputSchema)}\n\`\`\``, - ]; - if (errors && errors.length > 0) { - content.push(`Validation errors: ${errors.map((e) => e.message).join(', ')}`); - } - return buildMCPResponse(content); + if (!actor.ajvValidate(input)) { + const { errors } = actor.ajvValidate; + const content = [ + `Input validation failed for Actor '${actorName}'. Please ensure your input matches the Actor's input schema.`, + `Input schema:\n\`\`\`json\n${JSON.stringify(actor.inputSchema)}\n\`\`\``, + ]; + if (errors && errors.length > 0) { + content.push(`Validation errors: ${errors.map((e) => (e as { message?: string }).message).join(', ')}`); } + return buildMCPResponse(content); + } - const callResult = await callActorGetDataset( - actorName, - input, - apifyClient, - callOptions, - progressTracker, - extra.signal, - ); - - if (!callResult) { - // Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request - // https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements - return {}; - } + const callResult = await callActorGetDataset( + actorName, + input, + apifyClient, + callOptions, + progressTracker, + extra.signal, + ); + + if (!callResult) { + // Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request + // https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements + return {}; + } - const content = buildActorResponseContent(actorName, callResult); + const content = buildActorResponseContent(actorName, callResult); - return { content }; - } catch (error) { - log.error('Failed to call Actor', { error, actorName, performStep }); - return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]); - } - }, + return { content }; + } catch (error) { + log.error('Failed to call Actor', { error, actorName, performStep }); + return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]); + } }, }; diff --git a/src/tools/build.ts b/src/tools/build.ts index f9b577fd..8b3b132c 100644 --- a/src/tools/build.ts +++ b/src/tools/build.ts @@ -8,9 +8,10 @@ import { ACTOR_README_MAX_LENGTH, HelperTools } from '../const.js'; import type { ActorDefinitionPruned, ActorDefinitionWithDesc, - InternalTool, + InternalToolArgs, ISchemaProperties, ToolEntry, + ToolInputSchema, } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { filterSchemaProperties, shortenProperties } from './utils.js'; @@ -108,30 +109,26 @@ const getActorDefinitionArgsSchema = z.object({ */ export const actorDefinitionTool: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_GET_DETAILS, - // TODO: remove actorFullName from internal tools - actorFullName: HelperTools.ACTOR_GET_DETAILS, - description: 'Get documentation, readme, input schema and other details about an Actor. ' - + 'For example, when user says, I need to know more about web crawler Actor.' - + 'Get details for an Actor with with Actor ID or Actor full name, i.e. username/name.' - + `Limit the length of the README if needed.`, - inputSchema: zodToJsonSchema(getActorDefinitionArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(getActorDefinitionArgsSchema)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + name: HelperTools.ACTOR_GET_DETAILS, + description: 'Get documentation, readme, input schema and other details about an Actor. ' + + 'For example, when user says, I need to know more about web crawler Actor.' + + 'Get details for an Actor with with Actor ID or Actor full name, i.e. username/name.' + + `Limit the length of the README if needed.`, + inputSchema: zodToJsonSchema(getActorDefinitionArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getActorDefinitionArgsSchema)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; - const parsed = getActorDefinitionArgsSchema.parse(args); - const apifyClient = new ApifyClient({ token: apifyToken }); - const v = await getActorDefinition(parsed.actorName, apifyClient, parsed.limit); - if (!v) { - return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] }; - } - if (v && v.input && 'properties' in v.input && v.input) { - const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties }); - v.input.properties = shortenProperties(properties); - } - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + const parsed = getActorDefinitionArgsSchema.parse(args); + const apifyClient = new ApifyClient({ token: apifyToken }); + const v = await getActorDefinition(parsed.actorName, apifyClient, parsed.limit); + if (!v) { + return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] }; + } + if (v && v.input && 'properties' in v.input && v.input) { + const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties }); + v.input.properties = shortenProperties(properties); + } + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/dataset.ts b/src/tools/dataset.ts index a72ce760..6927b631 100644 --- a/src/tools/dataset.ts +++ b/src/tools/dataset.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { parseCommaSeparatedList } from '../utils/generic.js'; import { generateSchemaFromItems } from '../utils/schema-generation.js'; @@ -43,10 +43,8 @@ const getDatasetItemsArgs = z.object({ */ export const getDataset: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DATASET_GET, - actorFullName: HelperTools.DATASET_GET, - description: `Get metadata for a dataset (collection of structured data created by an Actor run). + name: HelperTools.DATASET_GET, + description: `Get metadata for a dataset (collection of structured data created by an Actor run). The results will include dataset details such as itemCount, schema, fields, and stats. Use fields to understand structure for filtering with ${HelperTools.DATASET_GET_ITEMS}. Note: itemCount updates may be delayed by up to ~5 seconds. @@ -57,30 +55,27 @@ USAGE: USAGE EXAMPLES: - user_input: Show info for dataset xyz123 - user_input: What fields does username~my-dataset have?`, - inputSchema: zodToJsonSchema(getDatasetArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getDatasetArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getDatasetArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const v = await client.dataset(parsed.datasetId).get(); - if (!v) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; - } - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getDatasetArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getDatasetArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getDatasetArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const v = await client.dataset(parsed.datasetId).get(); + if (!v) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + } + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const; /** * https://docs.apify.com/api/v2/dataset-items-get */ export const getDatasetItems: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DATASET_GET_ITEMS, - actorFullName: HelperTools.DATASET_GET_ITEMS, - description: `Retrieve dataset items with pagination, sorting, and field selection. + name: HelperTools.DATASET_GET_ITEMS, + description: `Retrieve dataset items with pagination, sorting, and field selection. Use clean=true to skip empty items and hidden fields. Include or omit fields using comma-separated lists. For nested objects, first flatten them (e.g., flatten="metadata"), then reference nested fields via dot notation (e.g., fields="metadata.url"). @@ -92,34 +87,33 @@ USAGE: USAGE EXAMPLES: - user_input: Get first 100 items from dataset abd123 - user_input: Get only metadata.url and title from dataset username~my-dataset (flatten metadata)`, - inputSchema: zodToJsonSchema(getDatasetItemsArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getDatasetItemsArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getDatasetItemsArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - - // Convert comma-separated strings to arrays - const fields = parseCommaSeparatedList(parsed.fields); - const omit = parseCommaSeparatedList(parsed.omit); - const flatten = parseCommaSeparatedList(parsed.flatten); - - const v = await client.dataset(parsed.datasetId).listItems({ - clean: parsed.clean, - offset: parsed.offset, - limit: parsed.limit, - fields, - omit, - desc: parsed.desc, - flatten, - }); - if (!v) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; - } - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getDatasetItemsArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getDatasetItemsArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getDatasetItemsArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + + // Convert comma-separated strings to arrays + const fields = parseCommaSeparatedList(parsed.fields); + const omit = parseCommaSeparatedList(parsed.omit); + const flatten = parseCommaSeparatedList(parsed.flatten); + + const v = await client.dataset(parsed.datasetId).listItems({ + clean: parsed.clean, + offset: parsed.offset, + limit: parsed.limit, + fields, + omit, + desc: parsed.desc, + flatten, + }); + if (!v) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + } + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const; const getDatasetSchemaArgs = z.object({ datasetId: z.string() @@ -141,10 +135,8 @@ const getDatasetSchemaArgs = z.object({ */ export const getDatasetSchema: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DATASET_SCHEMA_GET, - actorFullName: HelperTools.DATASET_SCHEMA_GET, - description: `Generate a JSON schema from a sample of dataset items. + name: HelperTools.DATASET_SCHEMA_GET, + description: `Generate a JSON schema from a sample of dataset items. The schema describes the structure of the data and can be used for validation, documentation, or processing. Use this to understand the dataset before fetching many items. @@ -154,46 +146,45 @@ USAGE: USAGE EXAMPLES: - user_input: Generate schema for dataset 34das2 using 10 items - user_input: Show schema of username~my-dataset (clean items only)`, - inputSchema: zodToJsonSchema(getDatasetSchemaArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getDatasetSchemaArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getDatasetSchemaArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - - // Get dataset items - const datasetResponse = await client.dataset(parsed.datasetId).listItems({ - clean: parsed.clean, - limit: parsed.limit, - }); - - if (!datasetResponse) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; - } - - const datasetItems = datasetResponse.items; - - if (datasetItems.length === 0) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' is empty.` }] }; - } - - // Generate schema using the shared utility - const schema = generateSchemaFromItems(datasetItems, { - limit: parsed.limit, - clean: parsed.clean, - arrayMode: parsed.arrayMode, - }); - - if (!schema) { - return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }] }; - } - - return { - content: [{ - type: 'text', - text: `\`\`\`json\n${JSON.stringify(schema)}\n\`\`\``, - }], - }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getDatasetSchemaArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getDatasetSchemaArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getDatasetSchemaArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + + // Get dataset items + const datasetResponse = await client.dataset(parsed.datasetId).listItems({ + clean: parsed.clean, + limit: parsed.limit, + }); + + if (!datasetResponse) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + } + + const datasetItems = datasetResponse.items; + + if (datasetItems.length === 0) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' is empty.` }] }; + } + + // Generate schema using the shared utility + const schema = generateSchemaFromItems(datasetItems, { + limit: parsed.limit, + clean: parsed.clean, + arrayMode: parsed.arrayMode, + }); + + if (!schema) { + return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }] }; + } + + return { + content: [{ + type: 'text', + text: `\`\`\`json\n${JSON.stringify(schema)}\n\`\`\``, + }], + }; + }, +} as const; diff --git a/src/tools/dataset_collection.ts b/src/tools/dataset_collection.ts index 2f29f553..9c8cfe13 100644 --- a/src/tools/dataset_collection.ts +++ b/src/tools/dataset_collection.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; const getUserDatasetsListArgs = z.object({ @@ -27,10 +27,8 @@ const getUserDatasetsListArgs = z.object({ */ export const getUserDatasetsList: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DATASET_LIST_GET, - actorFullName: HelperTools.DATASET_LIST_GET, - description: `List datasets (collections of Actor run data) for the authenticated user. + name: HelperTools.DATASET_LIST_GET, + description: `List datasets (collections of Actor run data) for the authenticated user. Actor runs automatically produce unnamed datasets (set unnamed=true to include them). Users can also create named datasets. The results will include datasets with itemCount, access settings, and usage stats, sorted by createdAt (ascending by default). @@ -42,19 +40,18 @@ USAGE: USAGE EXAMPLES: - user_input: List my last 10 datasets (newest first) - user_input: List unnamed datasets`, - inputSchema: zodToJsonSchema(getUserDatasetsListArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getUserDatasetsListArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getUserDatasetsListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const datasets = await client.datasets().list({ - limit: parsed.limit, - offset: parsed.offset, - desc: parsed.desc, - unnamed: parsed.unnamed, - }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(datasets)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getUserDatasetsListArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getUserDatasetsListArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getUserDatasetsListArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const datasets = await client.datasets().list({ + limit: parsed.limit, + offset: parsed.offset, + desc: parsed.desc, + unnamed: parsed.unnamed, + }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(datasets)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/fetch-actor-details.ts b/src/tools/fetch-actor-details.ts index 392e2237..1d3e85d9 100644 --- a/src/tools/fetch-actor-details.ts +++ b/src/tools/fetch-actor-details.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { fetchActorDetails } from '../utils/actor-details.js'; import { ajv } from '../utils/ajv.js'; @@ -15,10 +15,9 @@ const fetchActorDetailsToolArgsSchema = z.object({ export const fetchActorDetailsTool: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_GET_DETAILS, - description: `Get detailed information about an Actor by its ID or full name (format: "username/name", e.g., "apify/rag-web-browser"). -This returns the Actor’s title, description, URL, README (documentation), input schema, pricing/usage information, and basic stats. + name: HelperTools.ACTOR_GET_DETAILS, + description: `Get detailed information about an Actor by its ID or full name (format: "username/name", e.g., "apify/rag-web-browser"). +This returns the Actor's title, description, URL, README (documentation), input schema, pricing/usage information, and basic stats. Present the information in a user-friendly Actor card. USAGE: @@ -28,35 +27,34 @@ USAGE EXAMPLES: - user_input: How to use apify/rag-web-browser - user_input: What is the input schema for apify/rag-web-browser? - user_input: What is the pricing for apify/instagram-scraper?`, - inputSchema: zodToJsonSchema(fetchActorDetailsToolArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(fetchActorDetailsToolArgsSchema)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = fetchActorDetailsToolArgsSchema.parse(args); - const apifyClient = new ApifyClient({ token: apifyToken }); - const details = await fetchActorDetails(apifyClient, parsed.actor); - if (!details) { - return { - content: [{ type: 'text', text: `Actor information for '${parsed.actor}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }], - }; - } - - const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`; - // Add link to README title - details.readme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `); - - const content = [ - { type: 'text', text: `# Actor information\n${details.actorCard}` }, - { type: 'text', text: `${details.readme}` }, - ]; - - // Include input schema if it has properties - if (details.inputSchema.properties || Object.keys(details.inputSchema.properties).length !== 0) { - content.push({ type: 'text', text: `# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\`` }); - } - // Return the actor card, README, and input schema (if it has non-empty properties) as separate text blocks - // This allows better formatting in the final output - return { content }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(fetchActorDetailsToolArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(fetchActorDetailsToolArgsSchema)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = fetchActorDetailsToolArgsSchema.parse(args); + const apifyClient = new ApifyClient({ token: apifyToken }); + const details = await fetchActorDetails(apifyClient, parsed.actor); + if (!details) { + return { + content: [{ type: 'text', text: `Actor information for '${parsed.actor}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }], + }; + } + + const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`; + // Add link to README title + details.readme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `); + + const content = [ + { type: 'text', text: `# Actor information\n${details.actorCard}` }, + { type: 'text', text: `${details.readme}` }, + ]; + + // Include input schema if it has properties + if (details.inputSchema.properties || Object.keys(details.inputSchema.properties).length !== 0) { + content.push({ type: 'text', text: `# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\`` }); + } + // Return the actor card, README, and input schema (if it has non-empty properties) as separate text blocks + // This allows better formatting in the final output + return { content }; + }, +} as const; diff --git a/src/tools/fetch-apify-docs.ts b/src/tools/fetch-apify-docs.ts index 7eb80b94..80d9132e 100644 --- a/src/tools/fetch-apify-docs.ts +++ b/src/tools/fetch-apify-docs.ts @@ -5,7 +5,7 @@ import log from '@apify/log'; import { HelperTools } from '../const.js'; import { fetchApifyDocsCache } from '../state.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { htmlToMarkdown } from '../utils/html-to-md.js'; @@ -17,9 +17,8 @@ const fetchApifyDocsToolArgsSchema = z.object({ export const fetchApifyDocsTool: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DOCS_FETCH, - description: `Fetch the full content of an Apify documentation page by its URL. + name: HelperTools.DOCS_FETCH, + description: `Fetch the full content of an Apify documentation page by its URL. Use this after finding a relevant page with the ${HelperTools.DOCS_SEARCH} tool. USAGE: @@ -28,62 +27,60 @@ USAGE: USAGE EXAMPLES: - user_input: Fetch https://docs.apify.com/platform/actors/running#builds - user_input: Fetch https://docs.apify.com/academy`, - args: fetchApifyDocsToolArgsSchema, - inputSchema: zodToJsonSchema(fetchApifyDocsToolArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(fetchApifyDocsToolArgsSchema)), - call: async (toolArgs) => { - const { args } = toolArgs; + inputSchema: zodToJsonSchema(fetchApifyDocsToolArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(fetchApifyDocsToolArgsSchema)), + call: async (toolArgs: InternalToolArgs) => { + const { args } = toolArgs; - const parsed = fetchApifyDocsToolArgsSchema.parse(args); - const url = parsed.url.trim(); - const urlWithoutFragment = url.split('#')[0]; + const parsed = fetchApifyDocsToolArgsSchema.parse(args); + const url = parsed.url.trim(); + const urlWithoutFragment = url.split('#')[0]; - // Only allow URLs starting with https://docs.apify.com - if (!url.startsWith('https://docs.apify.com')) { - return { - content: [{ - type: 'text', - text: `Only URLs starting with https://docs.apify.com are allowed.`, - }], - }; - } + // Only allow URLs starting with https://docs.apify.com + if (!url.startsWith('https://docs.apify.com')) { + return { + content: [{ + type: 'text', + text: `Only URLs starting with https://docs.apify.com are allowed.`, + }], + }; + } - // Cache URL without fragment to avoid fetching the same page multiple times - let markdown = fetchApifyDocsCache.get(urlWithoutFragment); - // If the content is not cached, fetch it from the URL - if (!markdown) { - try { - const response = await fetch(url); - if (!response.ok) { - return { - content: [{ - type: 'text', - text: `Failed to fetch the documentation page at ${url}. Status: ${response.status} ${response.statusText}`, - }], - }; - } - const html = await response.text(); - markdown = htmlToMarkdown(html); - // Cache the processed Markdown content - // Use the URL without fragment as the key to avoid caching same page with different fragments - fetchApifyDocsCache.set(urlWithoutFragment, markdown); - } catch (error) { - log.error('Failed to fetch the documentation page', { url, error }); + // Cache URL without fragment to avoid fetching the same page multiple times + let markdown = fetchApifyDocsCache.get(urlWithoutFragment); + // If the content is not cached, fetch it from the URL + if (!markdown) { + try { + const response = await fetch(url); + if (!response.ok) { return { content: [{ type: 'text', - text: `Failed to fetch the documentation page at ${url}. Please check the URL and try again.`, + text: `Failed to fetch the documentation page at ${url}. Status: ${response.status} ${response.statusText}`, }], }; } + const html = await response.text(); + markdown = htmlToMarkdown(html); + // Cache the processed Markdown content + // Use the URL without fragment as the key to avoid caching same page with different fragments + fetchApifyDocsCache.set(urlWithoutFragment, markdown); + } catch (error) { + log.error('Failed to fetch the documentation page', { url, error }); + return { + content: [{ + type: 'text', + text: `Failed to fetch the documentation page at ${url}. Please check the URL and try again.`, + }], + }; } + } - return { - content: [{ - type: 'text', - text: `Fetched content from ${url}:\n\n${markdown}`, - }], - }; - }, - } as InternalTool, -}; + return { + content: [{ + type: 'text', + text: `Fetched content from ${url}:\n\n${markdown}`, + }], + }; + }, +} as const; diff --git a/src/tools/get-actor-output.ts b/src/tools/get-actor-output.ts index ba4aea81..d2b93c45 100644 --- a/src/tools/get-actor-output.ts +++ b/src/tools/get-actor-output.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools, SKYFIRE_TOOL_INSTRUCTIONS, TOOL_MAX_OUTPUT_CHARS } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { getValuesByDotKeys, parseCommaSeparatedList } from '../utils/generic.js'; @@ -65,10 +65,8 @@ export function cleanEmptyProperties(obj: unknown): unknown { */ export const getActorOutput: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_OUTPUT_GET, - actorFullName: HelperTools.ACTOR_OUTPUT_GET, - description: `Retrieve the output dataset items of a specific Actor run using its datasetId. + name: HelperTools.ACTOR_OUTPUT_GET, + description: `Retrieve the output dataset items of a specific Actor run using its datasetId. You can select specific fields to return (supports dot notation like "crawl.statusCode") and paginate results with offset and limit. This tool is a simplified version of the get-dataset-items tool, focused on Actor run outputs. @@ -85,78 +83,77 @@ USAGE EXAMPLES: - user_input: Return only crawl.statusCode and url from dataset aab123 Note: This tool is automatically included if the Apify MCP Server is configured with any Actor tools (e.g., "apify-slash-rag-web-browser") or tools that can interact with Actors (e.g., "call-actor", "add-actor").`, - inputSchema: zodToJsonSchema(getActorOutputArgs), - /** - * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. - */ - ajvValidate: ajv.compile({ ...zodToJsonSchema(getActorOutputArgs), additionalProperties: true }), - call: async (toolArgs) => { - const { args, apifyToken, apifyMcpServer } = toolArgs; + inputSchema: zodToJsonSchema(getActorOutputArgs) as ToolInputSchema, + /** + * Allow additional properties for Skyfire mode to pass `skyfire-pay-id`. + */ + ajvValidate: ajv.compile({ ...zodToJsonSchema(getActorOutputArgs), additionalProperties: true }), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, apifyMcpServer } = toolArgs; - /** + /** * In Skyfire mode, we check for the presence of `skyfire-pay-id`. * If it is missing, we return instructions to the LLM on how to create it and pass it to the tool. */ - if (apifyMcpServer.options.skyfireMode + if (apifyMcpServer.options.skyfireMode && args['skyfire-pay-id'] === undefined - ) { - return { - content: [{ - type: 'text', - text: SKYFIRE_TOOL_INSTRUCTIONS, - }], - }; - } - - /** + ) { + return { + content: [{ + type: 'text', + text: SKYFIRE_TOOL_INSTRUCTIONS, + }], + }; + } + + /** * Create Apify token, for Skyfire mode use `skyfire-pay-id` and for normal mode use `apifyToken`. */ - const apifyClient = apifyMcpServer.options.skyfireMode && typeof args['skyfire-pay-id'] === 'string' - ? new ApifyClient({ skyfirePayId: args['skyfire-pay-id'] }) - : new ApifyClient({ token: apifyToken }); - const parsed = getActorOutputArgs.parse(args); + const apifyClient = apifyMcpServer.options.skyfireMode && typeof args['skyfire-pay-id'] === 'string' + ? new ApifyClient({ skyfirePayId: args['skyfire-pay-id'] }) + : new ApifyClient({ token: apifyToken }); + const parsed = getActorOutputArgs.parse(args); - // Parse fields into array - const fieldsArray = parseCommaSeparatedList(parsed.fields); + // Parse fields into array + const fieldsArray = parseCommaSeparatedList(parsed.fields); - // TODO: we can optimize the API level field filtering in future - /** + // TODO: we can optimize the API level field filtering in future + /** * Only top-level fields can be filtered. * If a dot is present, filtering is done here and not at the API level. */ - const hasDot = fieldsArray.some((field) => field.includes('.')); - const response = await apifyClient.dataset(parsed.datasetId).listItems({ - offset: parsed.offset, - limit: parsed.limit, - fields: fieldsArray.length > 0 && !hasDot ? fieldsArray : undefined, - clean: true, - }); - - if (!response) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; - } - - let { items } = response; - // Apply field selection if specified - if (fieldsArray.length > 0) { - items = items.map((item) => getValuesByDotKeys(item, fieldsArray)); - } - - // Clean empty properties - const cleanedItems = items - .map((item) => cleanEmptyProperties(item)) - .filter((item) => item !== undefined); - - let outputText = `\`\`\`json\n${JSON.stringify(cleanedItems)}\n\`\`\``; - let truncated = false; - if (outputText.length > TOOL_MAX_OUTPUT_CHARS) { - outputText = outputText.slice(0, TOOL_MAX_OUTPUT_CHARS); - truncated = true; - } - if (truncated) { - outputText += `\n\n[Output was truncated to ${TOOL_MAX_OUTPUT_CHARS} characters to comply with the tool output limits.]`; - } - return { content: [{ type: 'text', text: outputText }] }; - }, - } as InternalTool, -}; + const hasDot = fieldsArray.some((field) => field.includes('.')); + const response = await apifyClient.dataset(parsed.datasetId).listItems({ + offset: parsed.offset, + limit: parsed.limit, + fields: fieldsArray.length > 0 && !hasDot ? fieldsArray : undefined, + clean: true, + }); + + if (!response) { + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + } + + let { items } = response; + // Apply field selection if specified + if (fieldsArray.length > 0) { + items = items.map((item) => getValuesByDotKeys(item, fieldsArray)); + } + + // Clean empty properties + const cleanedItems = items + .map((item) => cleanEmptyProperties(item)) + .filter((item) => item !== undefined); + + let outputText = `\`\`\`json\n${JSON.stringify(cleanedItems)}\n\`\`\``; + let truncated = false; + if (outputText.length > TOOL_MAX_OUTPUT_CHARS) { + outputText = outputText.slice(0, TOOL_MAX_OUTPUT_CHARS); + truncated = true; + } + if (truncated) { + outputText += `\n\n[Output was truncated to ${TOOL_MAX_OUTPUT_CHARS} characters to comply with the tool output limits.]`; + } + return { content: [{ type: 'text', text: outputText }] }; + }, +} as const; diff --git a/src/tools/get-html-skeleton.ts b/src/tools/get-html-skeleton.ts index ba73cdae..af5829b2 100644 --- a/src/tools/get-html-skeleton.ts +++ b/src/tools/get-html-skeleton.ts @@ -4,7 +4,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools, RAG_WEB_BROWSER, TOOL_MAX_OUTPUT_CHARS } from '../const.js'; import { getHtmlSkeletonCache } from '../state.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { isValidHttpUrl } from '../utils/generic.js'; import { stripHtml } from '../utils/html.js'; @@ -38,10 +38,8 @@ const getHtmlSkeletonArgs = z.object({ export const getHtmlSkeleton: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.GET_HTML_SKELETON, - actorFullName: HelperTools.GET_HTML_SKELETON, - description: `Retrieve the HTML skeleton (clean structure) of a webpage by stripping scripts, styles, and non-essential attributes. + name: HelperTools.GET_HTML_SKELETON, + description: `Retrieve the HTML skeleton (clean structure) of a webpage by stripping scripts, styles, and non-essential attributes. This keeps the core HTML structure, links, images, and data attributes for analysis. Supports optional JavaScript rendering for dynamic pages. The results will include a chunked HTML skeleton if the content is large. Use the chunk parameter to paginate through the output. @@ -52,60 +50,59 @@ USAGE: USAGE EXAMPLES: - user_input: Get HTML skeleton for https://example.com - user_input: Get next chunk of HTML skeleton for https://example.com (chunk=2)`, - inputSchema: zodToJsonSchema(getHtmlSkeletonArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getHtmlSkeletonArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getHtmlSkeletonArgs.parse(args); - - if (!isValidHttpUrl(parsed.url)) { - return buildMCPResponse([`The provided URL is not a valid HTTP or HTTPS URL: ${parsed.url}`]); + inputSchema: zodToJsonSchema(getHtmlSkeletonArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getHtmlSkeletonArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getHtmlSkeletonArgs.parse(args); + + if (!isValidHttpUrl(parsed.url)) { + return buildMCPResponse([`The provided URL is not a valid HTTP or HTTPS URL: ${parsed.url}`]); + } + + // Try to get from cache first + let strippedHtml = getHtmlSkeletonCache.get(parsed.url); + if (!strippedHtml) { + // Not in cache, call the Actor for scraping + const client = new ApifyClient({ token: apifyToken }); + + const run = await client.actor(RAG_WEB_BROWSER).call({ + query: parsed.url, + outputFormats: [ + 'html', + ], + scrapingTool: parsed.enableJavascript ? 'browser-playwright' : 'raw-http', + }); + + const datasetItems = await client.dataset(run.defaultDatasetId).listItems(); + if (datasetItems.items.length === 0) { + return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) did not return any output for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); } - // Try to get from cache first - let strippedHtml = getHtmlSkeletonCache.get(parsed.url); - if (!strippedHtml) { - // Not in cache, call the Actor for scraping - const client = new ApifyClient({ token: apifyToken }); - - const run = await client.actor(RAG_WEB_BROWSER).call({ - query: parsed.url, - outputFormats: [ - 'html', - ], - scrapingTool: parsed.enableJavascript ? 'browser-playwright' : 'raw-http', - }); - - const datasetItems = await client.dataset(run.defaultDatasetId).listItems(); - if (datasetItems.items.length === 0) { - return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) did not return any output for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); - } - - const firstItem = datasetItems.items[0] as unknown as ScrapedPageItem; - if (firstItem.crawl.httpStatusMessage.toLocaleLowerCase() !== 'ok') { - return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) returned an HTTP status ${firstItem.crawl.httpStatusCode} (${firstItem.crawl.httpStatusMessage}) for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); - } - - if (!firstItem.html) { - return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) did not return any HTML content for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); - } - - strippedHtml = stripHtml(firstItem.html); - getHtmlSkeletonCache.set(parsed.url, strippedHtml); + const firstItem = datasetItems.items[0] as unknown as ScrapedPageItem; + if (firstItem.crawl.httpStatusMessage.toLocaleLowerCase() !== 'ok') { + return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) returned an HTTP status ${firstItem.crawl.httpStatusCode} (${firstItem.crawl.httpStatusMessage}) for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); } - // Pagination logic - const totalLength = strippedHtml.length; - const chunkSize = TOOL_MAX_OUTPUT_CHARS; - const totalChunks = Math.ceil(totalLength / chunkSize); - const startIndex = (parsed.chunk - 1) * chunkSize; - const endIndex = Math.min(startIndex + chunkSize, totalLength); - const chunkContent = strippedHtml.slice(startIndex, endIndex); - const hasNextChunk = parsed.chunk < totalChunks; - - const chunkInfo = `\n\n--- Chunk ${parsed.chunk} of ${totalChunks} ---\n${hasNextChunk ? `Next chunk: ${parsed.chunk + 1}` : 'End of content'}`; - - return buildMCPResponse([chunkContent + chunkInfo]); - }, - } as InternalTool, -}; + if (!firstItem.html) { + return buildMCPResponse([`The scraping Actor (${RAG_WEB_BROWSER}) did not return any HTML content for the URL: ${parsed.url}. Please check the Actor run for more details: ${run.id}`]); + } + + strippedHtml = stripHtml(firstItem.html); + getHtmlSkeletonCache.set(parsed.url, strippedHtml); + } + + // Pagination logic + const totalLength = strippedHtml.length; + const chunkSize = TOOL_MAX_OUTPUT_CHARS; + const totalChunks = Math.ceil(totalLength / chunkSize); + const startIndex = (parsed.chunk - 1) * chunkSize; + const endIndex = Math.min(startIndex + chunkSize, totalLength); + const chunkContent = strippedHtml.slice(startIndex, endIndex); + const hasNextChunk = parsed.chunk < totalChunks; + + const chunkInfo = `\n\n--- Chunk ${parsed.chunk} of ${totalChunks} ---\n${hasNextChunk ? `Next chunk: ${parsed.chunk + 1}` : 'End of content'}`; + + return buildMCPResponse([chunkContent + chunkInfo]); + }, +} as const; diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index f5f2f8de..d0246e5f 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; export const addToolArgsSchema = z.object({ @@ -13,9 +13,8 @@ export const addToolArgsSchema = z.object({ }); export const addTool: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_ADD, - description: `Add an Actor or MCP server to the Apify MCP Server as an available tool. + name: HelperTools.ACTOR_ADD, + description: `Add an Actor or MCP server to the Apify MCP Server as an available tool. This does not execute the Actor; it only registers it so it can be called later. You can first discover Actors using the ${HelperTools.STORE_SEARCH} tool, then add the selected Actor as a tool. @@ -26,49 +25,48 @@ USAGE: USAGE EXAMPLES: - user_input: Add apify/rag-web-browser as a tool - user_input: Add apify/instagram-scraper as a tool`, - inputSchema: zodToJsonSchema(addToolArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), - // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool - call: async (toolArgs) => { - const { apifyMcpServer, apifyToken, args, extra: { sendNotification } } = toolArgs; - const parsed = addToolArgsSchema.parse(args); - if (apifyMcpServer.listAllToolNames().includes(parsed.actor)) { - return { - content: [{ - type: 'text', - text: `Actor ${parsed.actor} is already available. No new tools were added.`, - }], - }; - } - - const apifyClient = new ApifyClient({ token: apifyToken }); - const tools = await apifyMcpServer.loadActorsAsTools([parsed.actor], apifyClient); - /** - * If no tools were found, return a message that the Actor was not found - * instead of returning that non existent tool was added since the - * loadActorsAsTools method returns an empty array and does not throw an error. - */ - if (tools.length === 0) { - return { - content: [{ - type: 'text', - text: `Actor ${parsed.actor} not found, no tools were added.`, - }], - }; - } - - await sendNotification({ method: 'notifications/tools/list_changed' }); + inputSchema: zodToJsonSchema(addToolArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), + // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool + call: async (toolArgs: InternalToolArgs) => { + const { apifyMcpServer, apifyToken, args, extra: { sendNotification } } = toolArgs; + const parsed = addToolArgsSchema.parse(args); + if (apifyMcpServer.listAllToolNames().includes(parsed.actor)) { + return { + content: [{ + type: 'text', + text: `Actor ${parsed.actor} is already available. No new tools were added.`, + }], + }; + } + const apifyClient = new ApifyClient({ token: apifyToken }); + const tools = await apifyMcpServer.loadActorsAsTools([parsed.actor], apifyClient); + /** + * If no tools were found, return a message that the Actor was not found + * instead of returning that non existent tool was added since the + * loadActorsAsTools method returns an empty array and does not throw an error. + */ + if (tools.length === 0) { return { content: [{ type: 'text', - text: `Actor ${parsed.actor} has been added. Newly available tools: ${ - tools.map( - (t) => `${t.tool.name}`, - ).join(', ') - }.`, + text: `Actor ${parsed.actor} not found, no tools were added.`, }], }; - }, - } as InternalTool, -}; + } + + await sendNotification({ method: 'notifications/tools/list_changed' }); + + return { + content: [{ + type: 'text', + text: `Actor ${parsed.actor} has been added. Newly available tools: ${ + tools.map( + (t: ToolEntry) => `${t.name}`, + ).join(', ') + }.`, + }], + }; + }, +} as const; diff --git a/src/tools/key_value_store.ts b/src/tools/key_value_store.ts index eb9124e5..87f7b23b 100644 --- a/src/tools/key_value_store.ts +++ b/src/tools/key_value_store.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; const getKeyValueStoreArgs = z.object({ @@ -17,10 +17,8 @@ const getKeyValueStoreArgs = z.object({ */ export const getKeyValueStore: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.KEY_VALUE_STORE_GET, - actorFullName: HelperTools.KEY_VALUE_STORE_GET, - description: `Get details about a key-value store by ID or username~store-name. + name: HelperTools.KEY_VALUE_STORE_GET, + description: `Get details about a key-value store by ID or username~store-name. The results will include store metadata (ID, name, owner, access settings) and usage statistics. USAGE: @@ -29,17 +27,16 @@ USAGE: USAGE EXAMPLES: - user_input: Show info for key-value store username~my-store - user_input: Get details for store adb123`, - inputSchema: zodToJsonSchema(getKeyValueStoreArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getKeyValueStoreArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const store = await client.keyValueStore(parsed.storeId).get(); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(store)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getKeyValueStoreArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getKeyValueStoreArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const store = await client.keyValueStore(parsed.storeId).get(); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(store)}\n\`\`\`` }] }; + }, +} as const; const getKeyValueStoreKeysArgs = z.object({ storeId: z.string() @@ -59,10 +56,8 @@ const getKeyValueStoreKeysArgs = z.object({ */ export const getKeyValueStoreKeys: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.KEY_VALUE_STORE_KEYS_GET, - actorFullName: HelperTools.KEY_VALUE_STORE_KEYS_GET, - description: `List keys in a key-value store with optional pagination. + name: HelperTools.KEY_VALUE_STORE_KEYS_GET, + description: `List keys in a key-value store with optional pagination. The results will include keys and basic info about stored values (e.g., size). Use exclusiveStartKey and limit to paginate. @@ -72,20 +67,19 @@ USAGE: USAGE EXAMPLES: - user_input: List first 100 keys in store username~my-store - user_input: Continue listing keys in store a123 from key data.json`, - inputSchema: zodToJsonSchema(getKeyValueStoreKeysArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreKeysArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getKeyValueStoreKeysArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const keys = await client.keyValueStore(parsed.storeId).listKeys({ - exclusiveStartKey: parsed.exclusiveStartKey, - limit: parsed.limit, - }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(keys)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getKeyValueStoreKeysArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreKeysArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getKeyValueStoreKeysArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const keys = await client.keyValueStore(parsed.storeId).listKeys({ + exclusiveStartKey: parsed.exclusiveStartKey, + limit: parsed.limit, + }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(keys)}\n\`\`\`` }] }; + }, +} as const; const getKeyValueStoreRecordArgs = z.object({ storeId: z.string() @@ -101,10 +95,8 @@ const getKeyValueStoreRecordArgs = z.object({ */ export const getKeyValueStoreRecord: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.KEY_VALUE_STORE_RECORD_GET, - actorFullName: HelperTools.KEY_VALUE_STORE_RECORD_GET, - description: `Get a value stored in a key-value store under a specific key. + name: HelperTools.KEY_VALUE_STORE_RECORD_GET, + description: `Get a value stored in a key-value store under a specific key. The response preserves the original Content-Encoding; most clients handle decompression automatically. USAGE: @@ -113,14 +105,13 @@ USAGE: USAGE EXAMPLES: - user_input: Get record INPUT from store abc123 - user_input: Get record data.json from store username~my-store`, - inputSchema: zodToJsonSchema(getKeyValueStoreRecordArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreRecordArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getKeyValueStoreRecordArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(record)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getKeyValueStoreRecordArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreRecordArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getKeyValueStoreRecordArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(record)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/key_value_store_collection.ts b/src/tools/key_value_store_collection.ts index c62ed1ac..b4dae65f 100644 --- a/src/tools/key_value_store_collection.ts +++ b/src/tools/key_value_store_collection.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; const getUserKeyValueStoresListArgs = z.object({ @@ -27,10 +27,8 @@ const getUserKeyValueStoresListArgs = z.object({ */ export const getUserKeyValueStoresList: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.KEY_VALUE_STORE_LIST_GET, - actorFullName: HelperTools.KEY_VALUE_STORE_LIST_GET, - description: `List key-value stores owned by the authenticated user. + name: HelperTools.KEY_VALUE_STORE_LIST_GET, + description: `List key-value stores owned by the authenticated user. Actor runs automatically produce unnamed stores (set unnamed=true to include them). Users can also create named stores. The results will include basic info for each store, sorted by createdAt (ascending by default). @@ -42,19 +40,18 @@ USAGE: USAGE EXAMPLES: - user_input: List my last 10 key-value stores (newest first) - user_input: List unnamed key-value stores`, - inputSchema: zodToJsonSchema(getUserKeyValueStoresListArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getUserKeyValueStoresListArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getUserKeyValueStoresListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const stores = await client.keyValueStores().list({ - limit: parsed.limit, - offset: parsed.offset, - desc: parsed.desc, - unnamed: parsed.unnamed, - }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(stores)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getUserKeyValueStoresListArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getUserKeyValueStoresListArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getUserKeyValueStoresListArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const stores = await client.keyValueStores().list({ + limit: parsed.limit, + offset: parsed.offset, + desc: parsed.desc, + unnamed: parsed.unnamed, + }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(stores)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/run.ts b/src/tools/run.ts index 1261b909..7857f3d0 100644 --- a/src/tools/run.ts +++ b/src/tools/run.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; const getActorRunArgs = z.object({ @@ -24,10 +24,8 @@ const abortRunArgs = z.object({ */ export const getActorRun: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_RUNS_GET, - actorFullName: HelperTools.ACTOR_RUNS_GET, - description: `Get detailed information about a specific Actor run by runId. + name: HelperTools.ACTOR_RUNS_GET, + description: `Get detailed information about a specific Actor run by runId. The results will include run metadata (status, timestamps), performance stats, and resource IDs (datasetId, keyValueStoreId, requestQueueId). USAGE: @@ -36,20 +34,19 @@ USAGE: USAGE EXAMPLES: - user_input: Show details of run y2h7sK3Wc - user_input: What is the datasetId for run y2h7sK3Wc?`, - inputSchema: zodToJsonSchema(getActorRunArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getActorRunArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getActorRunArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const v = await client.run(parsed.runId).get(); - if (!v) { - return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] }; - } - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getActorRunArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getActorRunArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getActorRunArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const v = await client.run(parsed.runId).get(); + if (!v) { + return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] }; + } + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const; const GetRunLogArgs = z.object({ runId: z.string().describe('The ID of the Actor run.'), @@ -65,11 +62,9 @@ const GetRunLogArgs = z.object({ */ export const getActorRunLog: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_RUNS_LOG, - actorFullName: HelperTools.ACTOR_RUNS_LOG, - description: `Retrieve recent log lines for a specific Actor run. -The results will include the last N lines of the run’s log output (plain text). + name: HelperTools.ACTOR_RUNS_LOG, + description: `Retrieve recent log lines for a specific Actor run. +The results will include the last N lines of the run's log output (plain text). USAGE: - Use when you need to inspect recent logs to debug or monitor a run. @@ -77,29 +72,26 @@ USAGE: USAGE EXAMPLES: - user_input: Show last 20 lines of logs for run y2h7sK3Wc - user_input: Get logs for run y2h7sK3Wc`, - inputSchema: zodToJsonSchema(GetRunLogArgs), - ajvValidate: ajv.compile(zodToJsonSchema(GetRunLogArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = GetRunLogArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const v = await client.run(parsed.runId).log().get() ?? ''; - const lines = v.split('\n'); - const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); - return { content: [{ type: 'text', text }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(GetRunLogArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(GetRunLogArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = GetRunLogArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const v = await client.run(parsed.runId).log().get() ?? ''; + const lines = v.split('\n'); + const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); + return { content: [{ type: 'text', text }] }; + }, +} as const; /** * https://docs.apify.com/api/v2/actor-run-abort-post */ export const abortActorRun: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_RUNS_ABORT, - actorFullName: HelperTools.ACTOR_RUNS_ABORT, - description: `Abort an Actor run that is currently starting or running. + name: HelperTools.ACTOR_RUNS_ABORT, + description: `Abort an Actor run that is currently starting or running. For runs with status FINISHED, FAILED, ABORTING, or TIMED-OUT, this call has no effect. The results will include the updated run details after the abort request. @@ -109,14 +101,13 @@ USAGE: USAGE EXAMPLES: - user_input: Abort run y2h7sK3Wc - user_input: Gracefully abort run y2h7sK3Wc`, - inputSchema: zodToJsonSchema(abortRunArgs), - ajvValidate: ajv.compile(zodToJsonSchema(abortRunArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = abortRunArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(abortRunArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(abortRunArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = abortRunArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/run_collection.ts b/src/tools/run_collection.ts index 7e211f0c..3e07336c 100644 --- a/src/tools/run_collection.ts +++ b/src/tools/run_collection.ts @@ -3,7 +3,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; const getUserRunsListArgs = z.object({ @@ -27,10 +27,8 @@ const getUserRunsListArgs = z.object({ */ export const getUserRunsList: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.ACTOR_RUN_LIST_GET, - actorFullName: HelperTools.ACTOR_RUN_LIST_GET, - description: `List Actor runs for the authenticated user with optional filtering and sorting. + name: HelperTools.ACTOR_RUN_LIST_GET, + description: `List Actor runs for the authenticated user with optional filtering and sorting. The results will include run details (including datasetId and keyValueStoreId) and can be filtered by status. Valid statuses: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed), TIMING-OUT, TIMED-OUT, ABORTING, ABORTED. @@ -40,14 +38,13 @@ USAGE: USAGE EXAMPLES: - user_input: List my last 10 runs (newest first) - user_input: Show only SUCCEEDED runs`, - inputSchema: zodToJsonSchema(getUserRunsListArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const parsed = getUserRunsListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status }); - return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(runs)}\n\`\`\`` }] }; - }, - } as InternalTool, -}; + inputSchema: zodToJsonSchema(getUserRunsListArgs) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken } = toolArgs; + const parsed = getUserRunsListArgs.parse(args); + const client = new ApifyClient({ token: apifyToken }); + const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status }); + return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(runs)}\n\`\`\`` }] }; + }, +} as const; diff --git a/src/tools/search-apify-docs.ts b/src/tools/search-apify-docs.ts index 2520956a..b82bd64f 100644 --- a/src/tools/search-apify-docs.ts +++ b/src/tools/search-apify-docs.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { HelperTools } from '../const.js'; -import type { InternalTool, ToolEntry } from '../types.js'; +import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { searchApifyDocsCached } from '../utils/apify-docs.js'; @@ -28,9 +28,8 @@ Use this to paginate through the search results. For example, if you want to get export const searchApifyDocsTool: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.DOCS_SEARCH, - description: `Search Apify documentation using full-text search. + name: HelperTools.DOCS_SEARCH, + description: `Search Apify documentation using full-text search. You can use it to find relevant documentation based on keywords. Apify documentation has information about Apify console, Actors (development (actor.json, input schema, dataset schema, dockerfile), deployment, builds, runs), @@ -49,38 +48,36 @@ export const searchApifyDocsTool: ToolEntry = { - query: How to use create Apify Actor? - query: How to define Actor input schema? - query: How scrape with Crawlee?`, - args: searchApifyDocsToolArgsSchema, - inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)), - call: async (toolArgs) => { - const { args } = toolArgs; + inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)), + call: async (toolArgs: InternalToolArgs) => { + const { args } = toolArgs; - const parsed = searchApifyDocsToolArgsSchema.parse(args); - const query = parsed.query.trim(); + const parsed = searchApifyDocsToolArgsSchema.parse(args); + const query = parsed.query.trim(); - const resultsRaw = await searchApifyDocsCached(query); - const results = resultsRaw.slice(parsed.offset, parsed.offset + parsed.limit); + const resultsRaw = await searchApifyDocsCached(query); + const results = resultsRaw.slice(parsed.offset, parsed.offset + parsed.limit); - if (results.length === 0) { - return { - content: [{ - type: 'text', - text: `No results found for the query "${query}" with limit ${parsed.limit} and offset ${parsed.offset}. Try a different query or adjust the limit and offset.`, - }], - }; - } - - const textContent = `You can use the Apify docs fetch tool to retrieve the full content of a document by its URL. The document fragment refers to the section of the content containing the relevant part for the search result item. -Search results for "${query}": - -${results.map((result) => `- Document URL: ${result.url}${result.fragment ? `\n Document fragment: ${result.fragment}` : ''} - Content: ${result.content}`).join('\n\n')}`; + if (results.length === 0) { return { content: [{ type: 'text', - text: textContent, + text: `No results found for the query "${query}" with limit ${parsed.limit} and offset ${parsed.offset}. Try a different query or adjust the limit and offset.`, }], }; - }, - } as InternalTool, -}; + } + + const textContent = `You can use the Apify docs fetch tool to retrieve the full content of a document by its URL. The document fragment refers to the section of the content containing the relevant part for the search result item. +Search results for "${query}": + +${results.map((result) => `- Document URL: ${result.url}${result.fragment ? `\n Document fragment: ${result.fragment}` : ''} + Content: ${result.content}`).join('\n\n')}`; + return { + content: [{ + type: 'text', + text: textContent, + }], + }; + }, +} as const; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 761358cb..485ed169 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -4,7 +4,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js'; -import type { ActorPricingModel, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js'; +import type { ActorPricingModel, ExtendedActorStoreList, InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { formatActorToActorCard } from '../utils/actor-card.js'; import { ajv } from '../utils/ajv.js'; @@ -81,9 +81,8 @@ function filterRentalActors( */ export const searchActors: ToolEntry = { type: 'internal', - tool: { - name: HelperTools.STORE_SEARCH, - description: ` + name: HelperTools.STORE_SEARCH, + description: ` Search the Apify Store for Actors using keyword-based queries. Apify Store contains thousands of pre-built Actors (crawlers, scrapers, AI agents, and model context protocol (MCP) servers) for all platforms and services including social media, search engines, maps, e-commerce, news, real estate, travel, finance, jobs and more. @@ -110,32 +109,31 @@ Returns list of Actor cards with the following info: - **Pricing:** Details with pricing link - **Stats:** Usage, success rate, bookmarks - **Rating:** Out of 5 (if available) - - `, - inputSchema: zodToJsonSchema(searchActorsArgsSchema), - ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), - call: async (toolArgs) => { - const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs; - const parsed = searchActorsArgsSchema.parse(args); - let actors = await searchActorsByKeywords( - parsed.keywords, - apifyToken, - parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, - parsed.offset, - apifyMcpServer.options.skyfireMode ? true : undefined, // allowsAgenticUsers - filters Actors available for Agentic users - ); - actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); - const actorCards = actors.length === 0 ? [] : actors.map(formatActorToActorCard); - - const actorsText = actorCards.length - ? actorCards.join('\n\n') - : 'No Actors were found for the given search query. Please try different keywords or simplify your query.'; - - return { - content: [ - { - type: 'text', - text: ` +`, + inputSchema: zodToJsonSchema(searchActorsArgsSchema) as ToolInputSchema, + ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), + call: async (toolArgs: InternalToolArgs) => { + const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs; + const parsed = searchActorsArgsSchema.parse(args); + let actors = await searchActorsByKeywords( + parsed.keywords, + apifyToken, + parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, + parsed.offset, + apifyMcpServer.options.skyfireMode ? true : undefined, // allowsAgenticUsers - filters Actors available for Agentic users + ); + actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); + const actorCards = actors.length === 0 ? [] : actors.map(formatActorToActorCard); + + const actorsText = actorCards.length + ? actorCards.join('\n\n') + : 'No Actors were found for the given search query. Please try different keywords or simplify your query.'; + + return { + content: [ + { + type: 'text', + text: ` # Search results: - **Search query:** ${parsed.keywords} - **Number of Actors found:** ${actorCards.length} @@ -147,9 +145,8 @@ ${actorsText} If you need more detailed information about any of these Actors, including their input schemas and usage instructions, please use the ${HelperTools.ACTOR_GET_DETAILS} tool with the specific Actor name. If the search did not return relevant results, consider refining your keywords, use broader terms or removing less important words from the keywords. `, - }, - ], - }; - }, - } as HelperTool, -}; + }, + ], + }; + }, +} as const; diff --git a/src/types.ts b/src/types.ts index a90de611..2094d15d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import type { Notification, Prompt, Request } from '@modelcontextprotocol/sdk/types.js'; +import type { Notification, Prompt, Request, ToolSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition, ActorStoreList, PricingInfo } from 'apify-client'; +import type z from 'zod'; import type { ACTOR_PRICING_MODEL } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; @@ -60,24 +61,29 @@ export type ActorDefinitionPruned = Pick { /** AJV validation function for the input schema */ ajvValidate: ValidateFunction; } +/** + * Type for MCP SDK's inputSchema constraint. + * Extracted directly from the MCP SDK's ToolSchema to ensure alignment with the specification. + * The MCP SDK requires inputSchema to have type: "object" (literal) at the top level. + * Use this type when casting schemas that have type: string to the strict MCP format. + */ +export type ToolInputSchema = z.infer['inputSchema']; + /** * Interface for Actor-based tools - tools that wrap Apify Actors. - * Extends ToolBase with Actor-specific properties. + * Type discriminator: 'actor' */ export interface ActorTool extends ToolBase { + /** Type discriminator for actor tools */ + type: 'actor'; /** Full name of the Apify Actor (username/name) */ actorFullName: string; /** Optional memory limit in MB for the Actor execution */ @@ -111,10 +117,12 @@ export type InternalToolArgs = { } /** - * Interface for internal tools - tools implemented directly in the MCP server. - * Extends ToolBase with a call function implementation. + * Helper tool - tools implemented directly in the MCP server. + * Type discriminator: 'internal' */ export interface HelperTool extends ToolBase { + /** Type discriminator for helper/internal tools */ + type: 'internal'; /** * Executes the tool with the given arguments * @param toolArgs - Arguments and server references @@ -124,38 +132,34 @@ export interface HelperTool extends ToolBase { } /** -* Actorized MCP server tool where this MCP server acts as a proxy. -* Extends ToolBase with a tool-associated MCP server. -*/ + * Actor MCP tool - tools from Actorized MCP servers that this server proxies. + * Type discriminator: 'actor-mcp' + */ export interface ActorMcpTool extends ToolBase { - // Origin MCP server tool name is needed for the tool call + /** Type discriminator for actor MCP tools */ + type: 'actor-mcp'; + /** Origin MCP server tool name is needed for the tool call */ originToolName: string; - // ID of the Actorized MCP server - for example, apify/actors-mcp-server + /** ID of the Actorized MCP server - for example, apify/actors-mcp-server */ actorId: string; /** * ID of the Actorized MCP server the tool is associated with. * serverId is generated unique ID based on the serverUrl. */ serverId: string; - // Connection URL of the Actorized MCP server + /** Connection URL of the Actorized MCP server */ serverUrl: string; } /** - * Type discriminator for tools - indicates whether a tool is internal or Actor-based. - */ -export type ToolType = 'internal' | 'actor' | 'actor-mcp'; - -/** - * Wrapper interface that combines a tool with its type discriminator. - * Used to store and manage tools of different types uniformly. + * Discriminated union of all tool types. + * + * This is a discriminated union that ensures type safety: + * - When type is 'internal', tool is guaranteed to be HelperTool + * - When type is 'actor', tool is guaranteed to be ActorTool + * - When type is 'actor-mcp', tool is guaranteed to be ActorMcpTool */ -export interface ToolEntry { - /** Type of the tool (internal or actor) */ - type: ToolType; - /** The tool instance */ - tool: ActorTool | HelperTool | ActorMcpTool; -} +export type ToolEntry = HelperTool | ActorTool | ActorMcpTool; /** * Price for a single event in a specific tier. @@ -197,19 +201,6 @@ export type ExtendedPricingInfo = PricingInfo & { tieredPricing?: Partial>; }; -/** - * Interface for internal tools - tools implemented directly in the MCP server. - * Extends ToolBase with a call function implementation. - */ -export interface InternalTool extends ToolBase { - /** - * Executes the tool with the given arguments - * @param toolArgs - Arguments and server references - * @returns Promise resolving to the tool's output - */ - call: (toolArgs: InternalToolArgs) => Promise; -} - export type ToolCategory = keyof typeof toolCategories; /** * Selector for tools input - can be a category key or a specific tool name. diff --git a/src/utils/tools-loader.ts b/src/utils/tools-loader.ts index 9449652c..de89eb05 100644 --- a/src/utils/tools-loader.ts +++ b/src/utils/tools-loader.ts @@ -14,7 +14,7 @@ import { callActor } from '../tools/actor.js'; import { getActorOutput } from '../tools/get-actor-output.js'; import { addTool } from '../tools/helpers.js'; import { getActorsAsTools, toolCategories, toolCategoriesEnabledByDefault } from '../tools/index.js'; -import type { Input, InternalTool, InternalToolArgs, ToolCategory, ToolEntry } from '../types.js'; +import type { Input, InternalToolArgs, ToolCategory, ToolEntry } from '../types.js'; import { getExpectedToolsByCategories } from './tools.js'; // Lazily-computed cache of internal tools by name to avoid circular init issues. @@ -23,7 +23,7 @@ function getInternalToolByNameMap(): Map { if (!INTERNAL_TOOL_BY_NAME_CACHE) { const allInternal = getExpectedToolsByCategories(Object.keys(toolCategories) as ToolCategory[]); INTERNAL_TOOL_BY_NAME_CACHE = new Map( - allInternal.map((entry) => [entry.tool.name, entry]), + allInternal.map((entry) => [entry.name, entry]), ); } return INTERNAL_TOOL_BY_NAME_CACHE; @@ -114,7 +114,7 @@ export async function loadToolsFromInput( result.push(...internalSelections); // If add-actor mode is enabled, ensure add-actor tool is available alongside selected tools. if (addActorEnabled && !selectorsExplicitEmpty && !actorsExplicitlyEmpty) { - const hasAddActor = result.some((e) => e.tool.name === addTool.tool.name); + const hasAddActor = result.some((e) => e.name === addTool.name); if (!hasAddActor) result.push(addTool); } } else if (addActorEnabled && !actorsExplicitlyEmpty) { @@ -134,9 +134,9 @@ export async function loadToolsFromInput( * If there is any tool that in some way, even indirectly (like add-actor), allows calling * Actor, then we need to ensure the get-actor-output tool is available. */ - const hasCallActor = result.some((entry) => entry.tool.name === HelperTools.ACTOR_CALL); + const hasCallActor = result.some((entry) => entry.name === HelperTools.ACTOR_CALL); const hasActorTools = result.some((entry) => entry.type === 'actor'); - const hasAddActorTool = result.some((entry) => entry.tool.name === HelperTools.ACTOR_ADD); + const hasAddActorTool = result.some((entry) => entry.name === HelperTools.ACTOR_ADD); if (hasCallActor || hasActorTools || hasAddActorTool) { result.push(getActorOutput); } @@ -154,7 +154,7 @@ export async function loadToolsFromInput( // De-duplicate by tool name for safety const seen = new Set(); - const filtered = result.filter((entry) => !seen.has(entry.tool.name) && seen.add(entry.tool.name)); + const filtered = result.filter((entry) => !seen.has(entry.name) && seen.add(entry.name)); // TODO: rework this solition as it was quickly hacked together for hotfix // Deep clone except ajvValidate and call functions @@ -163,9 +163,9 @@ export async function loadToolsFromInput( const toolFunctions = new Map; call?:(args: InternalToolArgs) => Promise }>(); for (const entry of filtered) { if (entry.type === 'internal') { - toolFunctions.set(entry.tool.name, { ajvValidate: entry.tool.ajvValidate, call: (entry.tool as InternalTool).call }); + toolFunctions.set(entry.name, { ajvValidate: entry.ajvValidate, call: entry.call }); } else { - toolFunctions.set(entry.tool.name, { ajvValidate: entry.tool.ajvValidate }); + toolFunctions.set(entry.name, { ajvValidate: entry.ajvValidate }); } } @@ -176,13 +176,13 @@ export async function loadToolsFromInput( // restore the original functions for (const entry of cloned) { - const funcs = toolFunctions.get(entry.tool.name); + const funcs = toolFunctions.get(entry.name); if (funcs) { if (funcs.ajvValidate) { - entry.tool.ajvValidate = funcs.ajvValidate; + entry.ajvValidate = funcs.ajvValidate; } if (entry.type === 'internal' && funcs.call) { - (entry.tool as InternalTool).call = funcs.call; + entry.call = funcs.call; } } } diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 4356340a..f4a71de2 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -1,5 +1,5 @@ import { toolCategories } from '../tools/index.js'; -import type { InternalTool, ToolBase, ToolCategory, ToolEntry } from '../types.js'; +import type { ToolBase, ToolCategory, ToolEntry } from '../types.js'; /** * Returns a public version of the tool containing only fields that should be exposed publicly. @@ -25,7 +25,7 @@ export function getExpectedToolsByCategories(categories: ToolCategory[]): ToolEn * Returns the tool names for the given category names using getExpectedToolsByCategories. */ export function getExpectedToolNamesByCategories(categories: ToolCategory[]): string[] { - return getExpectedToolsByCategories(categories).map((tool) => tool.tool.name); + return getExpectedToolsByCategories(categories).map((tool) => tool.name); } /** @@ -34,8 +34,8 @@ export function getExpectedToolNamesByCategories(categories: ToolCategory[]): st */ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { // Store the original functions - const originalAjvValidate = toolEntry.tool.ajvValidate; - const originalCall = toolEntry.type === 'internal' ? (toolEntry.tool as InternalTool).call : undefined; + const originalAjvValidate = toolEntry.ajvValidate; + const originalCall = toolEntry.type === 'internal' ? toolEntry.call : undefined; // Create a deep copy using JSON serialization (excluding functions) const cloned = JSON.parse(JSON.stringify(toolEntry, (key, value) => { @@ -44,9 +44,9 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { })) as ToolEntry; // Restore the original functions - cloned.tool.ajvValidate = originalAjvValidate; + cloned.ajvValidate = originalAjvValidate; if (toolEntry.type === 'internal' && originalCall) { - (cloned.tool as InternalTool).call = originalCall; + cloned.call = originalCall; } return cloned; diff --git a/tests/integration/internals.test.ts b/tests/integration/internals.test.ts index 9f348bb0..8f613f35 100644 --- a/tests/integration/internals.test.ts +++ b/tests/integration/internals.test.ts @@ -33,7 +33,7 @@ describe('MCP server internals integration tests', () => { const names = actorsMcpServer.listAllToolNames(); // With enableAddingActors=true and no tools/actors, we should only have add-actor initially const expectedToolNames = [ - addTool.tool.name, + addTool.name, ACTOR_PYTHON_EXAMPLE, 'get-actor-output', ]; @@ -76,7 +76,7 @@ describe('MCP server internals integration tests', () => { expect(toolNotificationCount).toBe(1); expect(latestTools.length).toBe(numberOfTools + 1); expect(latestTools).toContain(actor); - expect(latestTools).toContain(addTool.tool.name); + expect(latestTools).toContain(addTool.name); // No default actors are present when only add-actor is enabled by default // Remove the Actor @@ -86,7 +86,7 @@ describe('MCP server internals integration tests', () => { expect(toolNotificationCount).toBe(2); expect(latestTools.length).toBe(numberOfTools); expect(latestTools).not.toContain(actor); - expect(latestTools).toContain(addTool.tool.name); + expect(latestTools).toContain(addTool.name); // No default actors are present by default in this mode }); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 22eece0f..0182f211 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -405,7 +405,7 @@ export function createIntegrationTestsSuite( const result = await client.callTool({ name: HelperTools.STORE_SEARCH, arguments: { - search: query, + keywords: query, limit: 5, }, }); @@ -422,7 +422,7 @@ export function createIntegrationTestsSuite( const result = await client.callTool({ name: HelperTools.STORE_SEARCH, arguments: { - search: 'rental', + keywords: 'rental', limit: 100, }, }); @@ -490,7 +490,7 @@ export function createIntegrationTestsSuite( const result = await client.callTool({ name: actorizedMCPSearchTool as string, arguments: { - search: ACTOR_MCP_SERVER_ACTOR_NAME, + keywords: ACTOR_MCP_SERVER_ACTOR_NAME, limit: 1, }, }); @@ -597,20 +597,20 @@ export function createIntegrationTestsSuite( const loadedTools = await client.listTools(); const toolNames = getToolNames(loadedTools); - expect(toolNames).toContain(addTool.tool.name); + expect(toolNames).toContain(addTool.name); }); it('should include add-actor when enableAddingActors is false and add-actor is selected directly', async () => { client = await createClientFn({ enableAddingActors: false, - tools: [addTool.tool.name], + tools: [addTool.name], }); const loadedTools = await client.listTools(); const toolNames = getToolNames(loadedTools); // Must include add-actor since it was selected directly - expect(toolNames).toContain(addTool.tool.name); + expect(toolNames).toContain(addTool.name); }); it('should handle multiple tool category keys input correctly', async () => { @@ -789,7 +789,7 @@ export function createIntegrationTestsSuite( ...toolCategories.docs, ...toolCategories.runs, ]; - const expectedToolNames = expectedTools.map((tool) => tool.tool.name); + const expectedToolNames = expectedTools.map((tool) => tool.name); expect(toolNames).toHaveLength(expectedToolNames.length); for (const expectedToolName of expectedToolNames) {