diff --git a/evals/evaluation-utils.ts b/evals/evaluation-utils.ts index 521de596..d2374e10 100644 --- a/evals/evaluation-utils.ts +++ b/evals/evaluation-utils.ts @@ -73,7 +73,7 @@ export function filterById(testCases: TestCase[], idPattern: string): TestCase[] export async function loadTools(): Promise { const apifyClient = new ApifyClient({ token: process.env.APIFY_API_TOKEN || '' }); const urlTools = await processParamsGetTools('', apifyClient); - return urlTools.map((t: ToolEntry) => getToolPublicFieldOnly(t.tool)) as ToolBase[]; + return urlTools.map((t: ToolEntry) => getToolPublicFieldOnly(t)) as ToolBase[]; } export function transformToolsToOpenAIFormat(tools: ToolBase[]): OpenAI.Chat.Completions.ChatCompletionTool[] { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9fc0b85c..074b92fe 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -180,7 +180,7 @@ export class ActorsMcpServer { * Loads missing toolNames from a provided list of tool names. * Skips toolNames that are already loaded and loads only the missing ones. * @param toolNames - Array of tool names to ensure are loaded - * @param apifyToken - Apify API token for authentication + * @param apifyClient */ public async loadToolsByName(toolNames: string[], apifyClient: ApifyClient) { const loadedTools = this.listAllToolNames(); @@ -215,7 +215,7 @@ export class ActorsMcpServer { * Load actors as tools, upsert them to the server, and return the tool entries. * This is a public method that wraps getActorsAsTools and handles the upsert operation. * @param actorIdsOrNames - Array of actor IDs or names to load as tools - * @param apifyToken - Apify API token for authentication + * @param apifyClient * @returns Promise - Array of loaded tool entries */ public async loadActorsAsTools(actorIdsOrNames: string[], apifyClient: ApifyClient): Promise { @@ -483,7 +483,9 @@ export class ActorsMcpServer { // Validate token if (!apifyToken && !this.options.skyfireMode) { - const msg = 'APIFY_TOKEN is required. It must be set in the environment variables or passed as a parameter in the body.'; + const msg = `APIFY_TOKEN is required but was not provided. +Please set the APIFY_TOKEN environment variable or pass it as a parameter in the request body. +You can obtain your Apify token from https://console.apify.com/account/integrations.`; log.softFail(msg, { statusCode: 400 }); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -507,7 +509,10 @@ export class ActorsMcpServer { const tool = Array.from(this.tools.values()) .find((t) => t.name === name || (t.type === 'actor' && t.actorFullName === name)); if (!tool) { - const msg = `Tool ${name} not found. Available tools: ${this.listToolNames().join(', ')}`; + const availableTools = this.listToolNames(); + const msg = `Tool "${name}" was not found. +Available tools: ${availableTools.length > 0 ? availableTools.join(', ') : 'none'}. +Please verify the tool name is correct. You can list all available tools using the tools/list request.`; log.softFail(msg, { statusCode: 404 }); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -516,7 +521,8 @@ export class ActorsMcpServer { ); } if (!args) { - const msg = `Missing arguments for tool ${name}`; + const msg = `Missing arguments for tool "${name}". +Please provide the required arguments for this tool. Check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool to see what parameters are required.`; log.softFail(msg, { statusCode: 400 }); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -529,7 +535,11 @@ export class ActorsMcpServer { args = decodeDotPropertyNames(args); 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)}`; + const errors = tool?.ajvValidate.errors || []; + const errorMessages = errors.map((e: { message?: string; instancePath?: string }) => `${e.instancePath || 'root'}: ${e.message || 'validation error'}`).join('; '); + const msg = `Invalid arguments for tool "${tool.name}". +Validation errors: ${errorMessages}. +Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool and ensure all required parameters are provided with correct types and values.`; log.softFail(msg, { statusCode: 400 }); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -569,16 +579,11 @@ export class ActorsMcpServer { try { client = await connectMCPClient(tool.serverUrl, apifyToken); if (!client) { - const msg = `Failed to connect to MCP server ${tool.serverUrl}`; - // Note: Timeout errors are already logged as warning in connectMCPClient - // This is a fallback log for when connection fails (client-side issue) + const msg = `Failed to connect to MCP server at "${tool.serverUrl}". +Please verify the server URL is correct and accessible, and ensure you have a valid Apify token with appropriate permissions.`; log.softFail(msg, { statusCode: 408 }); // 408 Request Timeout await this.server.sendLoggingMessage({ level: 'error', data: msg }); - return { - content: [ - { type: 'text', text: msg }, - ], - }; + return buildMCPResponse([msg], true); } // Only set up notification handlers if progressToken is provided by the client @@ -619,12 +624,7 @@ export class ActorsMcpServer { if (this.options.skyfireMode && args['skyfire-pay-id'] === undefined ) { - return { - content: [{ - type: 'text', - text: SKYFIRE_TOOL_INSTRUCTIONS, - }], - }; + return buildMCPResponse([SKYFIRE_TOOL_INSTRUCTIONS]); } // Create progress tracker if progressToken is available @@ -669,11 +669,15 @@ export class ActorsMcpServer { logHttpError(error, 'Error occurred while calling tool', { toolName: name }); const errorMessage = (error instanceof Error) ? error.message : 'Unknown error'; return buildMCPResponse([ - `Error calling tool ${name}: ${errorMessage}`, - ]); + `Error calling tool "${name}": ${errorMessage}. +Please verify the tool name, input parameters, and ensure all required resources are available.`, + ], true); } - const msg = `Unknown tool: ${name}`; + const availableTools = this.listToolNames(); + const msg = `Unknown tool type for "${name}". +Available tools: ${availableTools.length > 0 ? availableTools.join(', ') : 'none'}. +Please verify the tool name and ensure the tool is properly registered.`; log.softFail(msg, { statusCode: 404 }); await this.server.sendLoggingMessage({ level: 'error', diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 98a7710c..2aa8764c 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -39,8 +39,9 @@ export function getProxyMCPServerToolName(url: string, toolName: string): string /** * Process input parameters from URL and get tools * If URL contains query parameter `actors`, return tools from Actors otherwise return null. - * @param url - * @param apifyToken + * @param url The URL to process + * @param apifyClient The Apify client instance + * @param initializeRequestData Optional initialize request data */ export async function processParamsGetTools(url: string, apifyClient: ApifyClient, initializeRequestData?: InitializeRequest) { const input = parseInputParamsFromUrl(url); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 8d9d5560..5fe48d65 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -287,7 +287,7 @@ export async function getActorsAsTools( try { const actorDefinitionPruned = await getActorDefinition(actorIdOrName, apifyClient); if (!actorDefinitionPruned) { - log.info('Actor not found or definition is not available', { actorName: actorIdOrName }); + log.softFail('Actor not found or definition is not available', { actorName: actorIdOrName, statusCode: 404 }); return null; } // Cache the pruned Actor definition @@ -408,7 +408,9 @@ EXAMPLES: // 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.`]); + return buildMCPResponse([ + `This Actor (${actorName}) is an MCP server and cannot be accessed using a Skyfire token. To use this Actor, please provide a valid Apify token instead of a Skyfire token.`, + ], true); } try { @@ -421,7 +423,7 @@ EXAMPLES: try { client = await connectMCPClient(mcpServerUrl, apifyToken); if (!client) { - return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); + return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true); } const toolsResponse = await client.listTools(); @@ -436,7 +438,9 @@ EXAMPLES: // 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.`]); + return buildMCPResponse([`Actor information for '${baseActorName}' was not found. +Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists. +You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true); } const content = [ `Actor name: ${actorName}`, @@ -474,20 +478,20 @@ EXAMPLES: // 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.`]); + return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`], true); } // 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]); + return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG], true); } // Handle MCP tool calls if (mcpToolName) { if (!isActorMcpServer) { - return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]); + return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`], true); } const mcpServerUrl = mcpServerUrlOrFalse; @@ -495,7 +499,7 @@ EXAMPLES: try { client = await connectMCPClient(mcpServerUrl, apifyToken); if (!client) { - return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]); + return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true); } const result = await client.callTool({ @@ -513,7 +517,9 @@ EXAMPLES: const [actor] = await getActorsAsTools([actorName], apifyClient); if (!actor) { - return buildMCPResponse([`Actor '${actorName}' was not found.`]); + return buildMCPResponse([`Actor '${actorName}' was not found. +Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists. +You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true); } if (!actor.ajvValidate(input)) { @@ -548,7 +554,9 @@ EXAMPLES: return { content }; } catch (error) { logHttpError(error, 'Failed to call Actor', { actorName, performStep }); - return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]); + return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}. +Please verify the Actor name, input parameters, and ensure the Actor exists. +You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`], true); } }, }; diff --git a/src/tools/build.ts b/src/tools/build.ts index 1a77ff85..3687df29 100644 --- a/src/tools/build.ts +++ b/src/tools/build.ts @@ -19,8 +19,8 @@ import { filterSchemaProperties, shortenProperties } from './utils.js'; * First, fetch the Actor details to get the default build tag and buildId. * Then, fetch the build details and return actorName, description, and input schema. * @param {string} actorIdOrName - Actor ID or Actor full name. + * @param {ApifyClient} apifyClient - The Apify client instance. * @param {number} limit - Truncate the README to this limit. - * @param {string} apifyToken * @returns {Promise} - The actor definition with description or null if not found. */ export async function getActorDefinition( @@ -138,7 +138,7 @@ export const actorDefinitionTool: ToolEntry = { try { const v = await getActorDefinition(parsed.actorName, apifyClient, parsed.limit); if (!v) { - return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] }; + return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }], isError: true }; } if (v && v.input && 'properties' in v.input && v.input) { const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties }); @@ -151,6 +151,7 @@ export const actorDefinitionTool: ToolEntry = { type: 'text', text: `Failed to fetch Actor definition: ${error instanceof Error ? error.message : String(error)}`, }], + isError: true, }; } }, diff --git a/src/tools/dataset.ts b/src/tools/dataset.ts index d5d4b95a..b83ecefb 100644 --- a/src/tools/dataset.ts +++ b/src/tools/dataset.ts @@ -68,7 +68,7 @@ USAGE EXAMPLES: 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: `Dataset '${parsed.datasetId}' not found.` }], isError: true }; } return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; }, @@ -119,7 +119,7 @@ USAGE EXAMPLES: flatten, }); if (!v) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true }; } return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; }, @@ -175,7 +175,7 @@ USAGE EXAMPLES: }); if (!datasetResponse) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true }; } const datasetItems = datasetResponse.items; @@ -192,7 +192,7 @@ USAGE EXAMPLES: }); if (!schema) { - return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }] }; + return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }], isError: true }; } return { diff --git a/src/tools/fetch-actor-details.ts b/src/tools/fetch-actor-details.ts index e081b1bc..97f09dab 100644 --- a/src/tools/fetch-actor-details.ts +++ b/src/tools/fetch-actor-details.ts @@ -6,6 +6,7 @@ import { HelperTools } from '../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { fetchActorDetails } from '../utils/actor-details.js'; import { ajv } from '../utils/ajv.js'; +import { buildMCPResponse } from '../utils/mcp.js'; const fetchActorDetailsToolArgsSchema = z.object({ actor: z.string() @@ -40,26 +41,28 @@ USAGE EXAMPLES: 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 texts = [`Actor information for '${parsed.actor}' was not found. +Please verify Actor ID or name format and ensure that the Actor exists. +You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`, + ]; + return buildMCPResponse(texts, true); } 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}` }, + const texts = [ + `# Actor information\n${details.actorCard}`, + `${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\`\`\`` }); + texts.push(`# [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 }; + return buildMCPResponse(texts); }, } as const; diff --git a/src/tools/fetch-apify-docs.ts b/src/tools/fetch-apify-docs.ts index 21a4f5f9..8b3c9729 100644 --- a/src/tools/fetch-apify-docs.ts +++ b/src/tools/fetch-apify-docs.ts @@ -7,6 +7,7 @@ import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { htmlToMarkdown } from '../utils/html-to-md.js'; import { logHttpError } from '../utils/logging.js'; +import { buildMCPResponse } from '../utils/mcp.js'; const fetchApifyDocsToolArgsSchema = z.object({ url: z.string() @@ -42,12 +43,9 @@ USAGE EXAMPLES: // 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.`, - }], - }; + return buildMCPResponse([`Invalid URL: "${url}". +Only URLs starting with "https://docs.apify.com" are allowed. +Please provide a valid Apify documentation URL. You can find documentation URLs using the ${HelperTools.DOCS_SEARCH} tool.`], true); } // Cache URL without fragment to avoid fetching the same page multiple times @@ -57,17 +55,13 @@ USAGE EXAMPLES: try { const response = await fetch(url); if (!response.ok) { - // Create error object with statusCode for logHttpError const error = Object.assign(new Error(`HTTP ${response.status} ${response.statusText}`), { statusCode: response.status, }); logHttpError(error, 'Failed to fetch the documentation page', { url, statusText: response.statusText }); - return { - content: [{ - type: 'text', - text: `Failed to fetch the documentation page at ${url}. Status: ${response.status} ${response.statusText}`, - }], - }; + return buildMCPResponse([`Failed to fetch the documentation page at "${url}". +HTTP Status: ${response.status} ${response.statusText}. +Please verify the URL is correct and accessible. You can search for available documentation pages using the ${HelperTools.DOCS_SEARCH} tool.`], true); } const html = await response.text(); markdown = htmlToMarkdown(html); @@ -76,20 +70,12 @@ USAGE EXAMPLES: fetchApifyDocsCache.set(urlWithoutFragment, markdown); } catch (error) { logHttpError(error, 'Failed to fetch the documentation page', { url }); - return { - content: [{ - type: 'text', - text: `Failed to fetch the documentation page at ${url}. Please check the URL and try again.`, - }], - }; + return buildMCPResponse([`Failed to fetch the documentation page at "${url}". +Error: ${error instanceof Error ? error.message : String(error)}. +Please verify the URL is correct and accessible. You can search for available documentation pages using the ${HelperTools.DOCS_SEARCH} tool.`], true); } } - return { - content: [{ - type: 'text', - text: `Fetched content from ${url}:\n\n${markdown}`, - }], - }; + return buildMCPResponse([`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 e10b50c9..cb7eeb70 100644 --- a/src/tools/get-actor-output.ts +++ b/src/tools/get-actor-output.ts @@ -136,7 +136,7 @@ Note: This tool is automatically included if the Apify MCP Server is configured }); if (!response) { - return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; + return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true }; } let { items } = response; diff --git a/src/tools/get-html-skeleton.ts b/src/tools/get-html-skeleton.ts index 541bd039..76c1b661 100644 --- a/src/tools/get-html-skeleton.ts +++ b/src/tools/get-html-skeleton.ts @@ -62,7 +62,7 @@ USAGE EXAMPLES: const parsed = getHtmlSkeletonArgs.parse(args); if (!isValidHttpUrl(parsed.url)) { - return buildMCPResponse([`The provided URL is not a valid HTTP or HTTPS URL: ${parsed.url}`]); + return buildMCPResponse([`The provided URL is not a valid HTTP or HTTPS URL: ${parsed.url}`], true); } // Try to get from cache first @@ -81,16 +81,16 @@ USAGE EXAMPLES: 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}`]); + 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}`], true); } 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}`]); + 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}`], true); } 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}`]); + 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}`], true); } strippedHtml = stripHtml(firstItem.html); diff --git a/src/tools/run.ts b/src/tools/run.ts index ab4dbd8a..28a11564 100644 --- a/src/tools/run.ts +++ b/src/tools/run.ts @@ -47,7 +47,7 @@ USAGE EXAMPLES: 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: `Run with ID '${parsed.runId}' not found.` }], isError: true }; } return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] }; }, diff --git a/src/tools/search-apify-docs.ts b/src/tools/search-apify-docs.ts index d5762a5e..38a21389 100644 --- a/src/tools/search-apify-docs.ts +++ b/src/tools/search-apify-docs.ts @@ -5,6 +5,7 @@ import { HelperTools } from '../const.js'; import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { searchApifyDocsCached } from '../utils/apify-docs.js'; +import { buildMCPResponse } from '../utils/mcp.js'; const searchApifyDocsToolArgsSchema = z.object({ query: z.string() @@ -30,24 +31,24 @@ export const searchApifyDocsTool: ToolEntry = { type: 'internal', 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), - schedules, storages (datasets, key-value store), Proxy, Integrations, - Apify Academy (crawling and webscraping with Crawlee). +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), +schedules, storages (datasets, key-value store), Proxy, Integrations, +Apify Academy (crawling and webscraping with Crawlee), - The results will include the URL of the documentation page, a fragment identifier (if available), - and a limited piece of content that matches the search query. +The results will include the URL of the documentation page, a fragment identifier (if available), +and a limited piece of content that matches the search query. - Fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL. +Fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL. - USAGE: - - Use when user asks about Apify documentation, Actor development, Crawlee, or Apify platform. +USAGE: +- Use when user asks about Apify documentation, Actor development, Crawlee, or Apify platform. - USAGE EXAMPLES: - - query: How to use create Apify Actor? - - query: How to define Actor input schema? - - query: How scrape with Crawlee?`, +USAGE EXAMPLES: +- query: How to use create Apify Actor? +- query: How to define Actor input schema? +- query: How scrape with Crawlee?`, inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema) as ToolInputSchema, ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)), annotations: { @@ -65,12 +66,9 @@ export const searchApifyDocsTool: ToolEntry = { 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.`, - }], - }; + return buildMCPResponse([`No results found for the query "${query}" with limit ${parsed.limit} and offset ${parsed.offset}. +Please try a different query with different keywords, or adjust the limit and offset parameters. +You can also try using more specific or alternative keywords related to your search topic.`]); } 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. @@ -78,11 +76,6 @@ 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, - }], - }; + return buildMCPResponse([textContent]); }, } as const; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index fa1bd165..de35f412 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -7,6 +7,7 @@ import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js'; import type { ActorPricingModel, ExtendedActorStoreList, InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js'; import { formatActorToActorCard } from '../utils/actor-card.js'; import { ajv } from '../utils/ajv.js'; +import { buildMCPResponse } from '../utils/mcp.js'; export async function searchActorsByKeywords( search: string, @@ -130,15 +131,14 @@ Returns list of Actor cards with the following info: 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.'; + if (actorCards.length === 0) { + return buildMCPResponse([`No Actors were found for the search query "${parsed.keywords}". +Please try different keywords or simplify your query. Consider using more specific platform names (e.g., "Instagram", "Twitter") and data types (e.g., "posts", "products") rather than generic terms like "scraper" or "crawler".`]); + } - return { - content: [ - { - type: 'text', - text: ` + const actorsText = actorCards.join('\n\n'); + + return buildMCPResponse([` # Search results: - **Search query:** ${parsed.keywords} - **Number of Actors found:** ${actorCards.length} @@ -149,9 +149,6 @@ ${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 const; diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 45f6926c..6bb7fccb 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -1,8 +1,11 @@ /** * Helper to build a response for MCP from an array of text strings. + * @param texts - Array of text strings to include in the response + * @param isError - Optional flag to mark the response as an error (default: false) */ -export function buildMCPResponse(texts: string[]) { +export function buildMCPResponse(texts: string[], isError = false) { return { content: texts.map((text) => ({ type: 'text', text })), + isError, }; } diff --git a/src/utils/tools-loader.ts b/src/utils/tools-loader.ts index de89eb05..57e5b563 100644 --- a/src/utils/tools-loader.ts +++ b/src/utils/tools-loader.ts @@ -34,7 +34,8 @@ function getInternalToolByNameMap(): Map { * This function is used by both the stdio.ts and the processParamsGetTools function. * * @param input The processed Input object - * @param apifyToken The Apify API token + * @param apifyClient The Apify client instance + * @param _initializeRequestData Optional initialize request data * @returns An array of tool entries */ export async function loadToolsFromInput( diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 0182f211..e3654036 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -1043,7 +1043,7 @@ export function createIntegrationTestsSuite( await client.close(); }); - it('should return error message when tryging to call MCP server Actor without tool name in actor parameter', async () => { + it('should return error message when trying to call MCP server Actor without tool name in actor parameter', async () => { client = await createClientFn({ tools: ['actors'] }); const response = await client.callTool({ @@ -1059,6 +1059,7 @@ export function createIntegrationTestsSuite( const content = response.content as { text: string }[]; expect(content.length).toBeGreaterThan(0); expect(content[0].text).toContain(CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG); + expect(response.isError).toBe(true); await client.close(); });