From d78d12a99a456e4582d5d5ebb533c28dc64de05d Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 09:29:21 +0200 Subject: [PATCH 01/10] fix: standardize tool description formatting with string concatenation Replace inconsistent template literal indentation with string concatenation for all tool descriptions to ensure clean code and output text. --- src/tools/actor.ts | 12 ++++++------ src/tools/get-actor-details.ts | 13 +++++++++---- src/tools/helpers.ts | 20 ++++++++++---------- src/tools/run_collection.ts | 6 +++--- src/tools/search-apify-docs.ts | 9 ++++++--- src/tools/store_collection.ts | 15 ++++++++------- 6 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 47cc3657..98af9adb 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -330,12 +330,12 @@ export const callActor: ToolEntry = { return { content: [{ type: 'text', - text: `Actor '${actorName}' is not added. ${toolsText} -To use this MCP server, specify the actors with the parameter, for example: -?actors=apify/instagram-scraper,apify/website-content-crawler -or with the CLI: ---actors "apify/instagram-scraper,apify/website-content-crawler" -You can only use actors that are included in the list; actors not in the list cannot be used.`, + text: `Actor '${actorName}' is not added. ${toolsText}\n` + + 'To use this MCP server, specify the actors with the parameter, for example:\n' + + '?actors=apify/instagram-scraper,apify/website-content-crawler\n' + + 'or with the CLI:\n' + + '--actors "apify/instagram-scraper,apify/website-content-crawler"\n' + + 'You can only use actors that are included in the list; actors not in the list cannot be used.', }], }; } diff --git a/src/tools/get-actor-details.ts b/src/tools/get-actor-details.ts index ec741601..d3507b4c 100644 --- a/src/tools/get-actor-details.ts +++ b/src/tools/get-actor-details.ts @@ -46,10 +46,15 @@ export const getActorDetailsTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_GET_DETAILS, - description: `Retrieve information about an Actor by its ID or full name. -The Actor name is always composed of "username/name", for example, "apify/rag-web-browser". -This tool returns information about the Actor, including whether it is public or deprecated, when it was created or modified, the categories in which the Actor is listed, a description, a README (the Actor's documentation), the input schema, and usage statistics - such as how many users are using it and the number of failed runs of the Actor. -For example, use this tool when a user wants to know more about a specific Actor or wants to use optional or advanced parameters of the Actor that are not listed in the default Actor tool input schema - so you know the details and how to pass them.`, + description: `Retrieve information about an Actor by its ID or full name.\n` + + 'The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n' + + 'This tool returns information about the Actor, including whether it is public or deprecated, ' + + 'when it was created or modified, the categories in which the Actor is listed, a description, ' + + 'a README (the Actor\'s documentation), the input schema, and usage statistics - such as how many users are using it ' + + 'and the number of failed runs of the Actor.\n' + + 'For example, use this tool when a user wants to know more about a specific Actor or wants to use optional ' + + 'or advanced parameters of the Actor that are not listed in the default Actor tool input schema - ' + + 'so you know the details and how to pass them.', inputSchema: zodToJsonSchema(getActorDetailsToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(getActorDetailsToolArgsSchema)), call: async (toolArgs) => { diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 8dda1147..deba994c 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -39,13 +39,12 @@ export const addTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_ADD, - description: - `Add an Actor or MCP server to the available tools of the Apify MCP server. -A tool is an Actor or MCP server that can be called by the user. -Do not execute the tool, only add it and list it in the available tools. -For example, when a user wants to scrape a website, first search for relevant Actors -using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use, -add it as a tool to the Apify MCP server.`, + description: `Add an Actor or MCP server to the available tools of the Apify MCP server.\n` + + 'A tool is an Actor or MCP server that can be called by the user.\n' + + 'Do not execute the tool, only add it and list it in the available tools.\n' + + 'For example, when a user wants to scrape a website, first search for relevant Actors\n' + + `using ${HelperTools.STORE_SEARCH} tool, and once the user selects one they want to use,\n` + + 'add it as a tool to the Apify MCP server.', inputSchema: zodToJsonSchema(addToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool @@ -120,9 +119,10 @@ export const helpTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.APIFY_MCP_HELP_TOOL, - description: `Helper tool to get information on how to use and troubleshoot the Apify MCP server. -This tool always returns the same help message with information about the server and how to use it. -ALWAYS CALL THIS TOOL AT THE BEGINNING OF THE CONVERSATION SO THAT YOU HAVE INFORMATION ABOUT THE APIFY MCP SERVER IN CONTEXT, OR WHEN YOU ENCOUNTER ANY ISSUES WITH THE MCP SERVER OR ITS TOOLS.`, + description: `Helper tool to get information on how to use and troubleshoot the Apify MCP server.\n` + + 'This tool always returns the same help message with information about the server and how to use it.\n' + + 'ALWAYS CALL THIS TOOL AT THE BEGINNING OF THE CONVERSATION SO THAT YOU HAVE INFORMATION ABOUT THE APIFY MCP SERVER IN CONTEXT, ' + + 'OR WHEN YOU ENCOUNTER ANY ISSUES WITH THE MCP SERVER OR ITS TOOLS.', inputSchema: zodToJsonSchema(helpToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(helpToolArgsSchema)), call: async () => { diff --git a/src/tools/run_collection.ts b/src/tools/run_collection.ts index 03560a0f..ff4de217 100644 --- a/src/tools/run_collection.ts +++ b/src/tools/run_collection.ts @@ -32,9 +32,9 @@ export const getUserRunsList: ToolEntry = { tool: { name: HelperTools.ACTOR_RUN_LIST_GET, actorFullName: HelperTools.ACTOR_RUN_LIST_GET, - description: `Gets a paginated list of Actor runs with run details, datasetId, and keyValueStoreId. - Filter by status: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed), - TIMING-OUT (timing out), TIMED-OUT (timed out), ABORTING (being aborted), ABORTED (aborted).`, + description: `Gets a paginated list of Actor runs with run details, datasetId, and keyValueStoreId.\n` + + 'Filter by status: READY (not allocated), RUNNING (executing), SUCCEEDED (finished), FAILED (failed),\n' + + 'TIMING-OUT (timing out), TIMED-OUT (timed out), ABORTING (being aborted), ABORTED (aborted).', inputSchema: zodToJsonSchema(getUserRunsListArgs), ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)), call: async (toolArgs) => { diff --git a/src/tools/search-apify-docs.ts b/src/tools/search-apify-docs.ts index 750e935d..ddf0ee6d 100644 --- a/src/tools/search-apify-docs.ts +++ b/src/tools/search-apify-docs.ts @@ -30,9 +30,12 @@ export const searchApifyDocsTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.DOCS_SEARCH, - description: `Apify documentation search tool. This tool allows you to search the Apify documentation using Algolia's full-text search. -You can use it to find relevant documentation pages based on keywords. 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. You can then fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL. -Use this tool when a user asks for help with Apify documentation or when you need to find relevant documentation pages based on keywords. For example, when a user wants to build an Apify Actor, you can search "How to build Actors" to find relevant guidance.`, + description: `Apify documentation search tool. This tool allows you to search the Apify documentation using Algolia's full-text search.\n` + + 'You can use it to find relevant documentation pages based on keywords. 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. ' + + `You can then fetch the full content of the document using the ${HelperTools.DOCS_FETCH} tool by providing the URL.\n` + + 'Use this tool when a user asks for help with Apify documentation or when you need to find relevant documentation pages based on keywords. ' + + 'For example, when a user wants to build an Apify Actor, you can search "How to build Actors" to find relevant guidance.', args: searchApifyDocsToolArgsSchema, inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)), diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 04018fc7..63402594 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -97,13 +97,14 @@ export const searchActors: ToolEntry = { type: 'internal', tool: { name: HelperTools.STORE_SEARCH, - description: `Discover available Actors or MCP servers (which are also considered Actors in the context of Apify) in the Apify Store. -This tool uses full-text search, so you MUST use simple space-separated keywords, such as "web scraping", "data extraction", or "playwright browser mcp". -This tool returns a list of Actors with basic information, including descriptions, pricing models, usage statistics, and user ratings. -Prefer Actors with more users, stars, and runs. -You may need to use this tool several times to find the right Actor. -Limit the number of results returned, but ensure that relevant results are included. -This is not a general search tool; it is designed specifically to search for Actors in the Apify Store.`, + description: `Discover available Actors or MCP servers (which are also considered Actors in the context of Apify) in the Apify Store.\n` + + 'This tool uses full-text search, so you MUST use simple space-separated keywords, such as "web scraping", ' + + '"data extraction", or "playwright browser mcp".\n' + + 'This tool returns a list of Actors with basic information, including descriptions, pricing models, usage statistics, and user ratings.\n' + + 'Prefer Actors with more users, stars, and runs.\n' + + 'You may need to use this tool several times to find the right Actor.\n' + + 'Limit the number of results returned, but ensure that relevant results are included.\n' + + 'This is not a general search tool; it is designed specifically to search for Actors in the Apify Store.', inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { From c1f4ce1f4613e78980ef5e8eed12300c4ffbd837 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 14:21:25 +0200 Subject: [PATCH 02/10] feat: Return markdown from get-actor-details.ts --- README.md | 17 +++-- src/const.ts | 2 + src/tools/get-actor-details.ts | 120 ++++++++++++++------------------- 3 files changed, 66 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index ec9e293b..0c77d986 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,21 @@ Build the `actor-mcp-server` package: npm run build ``` -## Debugging +## Start HTTP streamable MCP server -Since MCP servers operate over standard input/output (stdio), debugging can be challenging. -For the best debugging experience, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). +Run using Apify CLI: -You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command: +```bash +export APIFY_TOKEN="your-apify-token" +export APIFY_META_ORIGIN=STANDBY +apify run -p +``` + +Once the server is running, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to debug the server exposed at `http://localhost:3001`. + +## Start standard input/output (stdio) MCP server + +You can launch the MCP Inspector with this command: ```bash export APIFY_TOKEN="your-apify-token" diff --git a/src/const.ts b/src/const.ts index 336696ba..2e6797c9 100644 --- a/src/const.ts +++ b/src/const.ts @@ -78,3 +78,5 @@ export const ALGOLIA = { }; export const PROGRESS_NOTIFICATION_INTERVAL_MS = 5_000; // 5 seconds + +export const APIFY_STORE_URL = 'https://apify.com'; diff --git a/src/tools/get-actor-details.ts b/src/tools/get-actor-details.ts index d3507b4c..99164eac 100644 --- a/src/tools/get-actor-details.ts +++ b/src/tools/get-actor-details.ts @@ -1,8 +1,9 @@ +import type { Actor, Build } from 'apify-client'; import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; -import { HelperTools } from '../const.js'; +import { APIFY_STORE_URL, HelperTools } from '../const.js'; import type { ExtendedPricingInfo, IActorInputSchema, InternalTool, ToolEntry } from '../types.js'; import { ajv } from '../utils/ajv.js'; import { getCurrentPricingInfo, pricingInfoToString } from '../utils/pricing-info.js'; @@ -14,47 +15,34 @@ const getActorDetailsToolArgsSchema = z.object({ .describe(`Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".`), }); -interface IGetActorDetailsToolResult { - id: string; - actorFullName: string; - - isPublic: boolean; - isDeprecated: boolean; - createdAt: string; - modifiedAt: string; - - categories?: string[]; - description: string; - readme: string; - - inputSchema: IActorInputSchema; - - pricingInfo: string; // We convert the pricing info into a string representation - - usageStatistics: { - totalUsers: { - allTime: number; - last7Days: number; - last30Days: number; - last90Days: number; - }; - failedRunsInLast30Days: number | string; // string for 'unknown' case - } +// Helper function to format categories from uppercase with underscores to proper case +function formatCategories(categories?: string[]): string[] { + if (!categories) return []; + + return categories.map((category) => { + const formatted = category + .toLowerCase() + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + // Special case for MCP server, AI, and SEO tools + return formatted.replace('Mcp Server', 'MCP Server').replace('Ai', 'AI').replace('Seo', 'SEO'); + }); } export const getActorDetailsTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_GET_DETAILS, - description: `Retrieve information about an Actor by its ID or full name.\n` - + 'The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n' - + 'This tool returns information about the Actor, including whether it is public or deprecated, ' - + 'when it was created or modified, the categories in which the Actor is listed, a description, ' - + 'a README (the Actor\'s documentation), the input schema, and usage statistics - such as how many users are using it ' - + 'and the number of failed runs of the Actor.\n' - + 'For example, use this tool when a user wants to know more about a specific Actor or wants to use optional ' - + 'or advanced parameters of the Actor that are not listed in the default Actor tool input schema - ' - + 'so you know the details and how to pass them.', + description: `Retrieve comprehensive details about an Actor using its ID or full name.\n` + + `This tool provides the Actor's title, description, URL, documentation (README), input schema, categories, pricing, and usage statistics.\n` + + `Specify the Actor name in the format "username/name" (e.g., "apify/rag-web-browser").\n` + + `The response is formatted in markdown and should be rendered as-is.\n` + + `USAGE:\n` + + `- Use when a user requests information about an Actor, such as its details, description, input schema, or documentation.\n` + + `EXAMPLES:\n` + + `- user_input: How to use apify/rag-web-browser\n` + + `- user_input: What is the input schema for apify/rag-web-browser`, inputSchema: zodToJsonSchema(getActorDetailsToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(getActorDetailsToolArgsSchema)), call: async (toolArgs) => { @@ -63,7 +51,7 @@ export const getActorDetailsTool: ToolEntry = { const parsed = getActorDetailsToolArgsSchema.parse(args); const client = new ApifyClient({ token: apifyToken }); - const [actorInfo, buildInfo] = await Promise.all([ + const [actorInfo, buildInfo]: [Actor | undefined, Build | undefined] = await Promise.all([ client.actor(parsed.actor).get(), client.actor(parsed.actor).defaultBuild().then(async (build) => build.get()), ]); @@ -83,40 +71,34 @@ export const getActorDetailsTool: ToolEntry = { const currentPricingInfo = getCurrentPricingInfo(actorInfo.pricingInfos || [], new Date()); - const result: IGetActorDetailsToolResult = { - id: actorInfo.id, - actorFullName: `${actorInfo.username}/${actorInfo.name}`, - - isPublic: actorInfo.isPublic, - isDeprecated: actorInfo.isDeprecated || false, - createdAt: actorInfo.createdAt.toISOString(), - modifiedAt: actorInfo.modifiedAt.toISOString(), - - categories: actorInfo.categories, - description: actorInfo.description || 'No description provided.', - readme: buildInfo.actorDefinition.readme || 'No README provided.', - - inputSchema, - - pricingInfo: pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null)), + // Format categories for display + const formattedCategories = formatCategories(actorInfo.categories); + + // Note: In the public API, we are missing maintainedByApify property, so we cannot use it here. + // Note: Actor rating is not in public API, we need to add it (actorUtils.getActorReviewRatingNumber(actorId)) + const actorFullName = `${actorInfo.username}/${actorInfo.name}`; + const markdownLines = [ + `Actor details (always present Actor information in this format, always include URL):\n`, + `# [${actorInfo.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`, + `**Developed by:** ${actorInfo.username} Maintained by ${actorInfo.username === 'apify' ? '(Apify)' : '(community)'}`, + `**Description:** ${actorInfo.description || 'No description provided.'}`, + `**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`, + `**Pricing:** ${pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null))}`, + `**Stats:** ${actorInfo.stats.totalUsers.toLocaleString()} total users, ${actorInfo.stats.totalUsers30Days.toLocaleString()} monthly users`, + `Last modified: ${actorInfo.modifiedAt.toISOString()}`, + ]; + if (actorInfo.isDeprecated) { + markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.'); + } + const actorCard = markdownLines.join('\n'); - usageStatistics: { - totalUsers: { - allTime: actorInfo.stats.totalUsers, - last7Days: actorInfo.stats.totalUsers7Days, - last30Days: actorInfo.stats.totalUsers30Days, - last90Days: actorInfo.stats.totalUsers90Days, - }, - failedRunsInLast30Days: ( - 'publicActorRunStats30Days' in actorInfo.stats && 'FAILED' in (actorInfo.stats.publicActorRunStats30Days as object) - ) ? (actorInfo.stats.publicActorRunStats30Days as { FAILED: number }).FAILED : 'unknown', - }, - }; return { - content: [{ - type: 'text', - text: JSON.stringify(result), - }], + content: [ + { type: 'text', text: actorCard }, + // LLM properly format Actor card, if README and input schema are separate text blocks + { type: 'text', text: `**README**:\n\n${buildInfo.actorDefinition.readme || 'No README provided.'}` }, + { type: 'text', text: `**Input Schema**:\n\n${JSON.stringify(inputSchema, null, 0)}` }, + ], }; }, } as InternalTool, From e5aea9623b88000157a3e8243616422ad7ec8c22 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 15:06:29 +0200 Subject: [PATCH 03/10] feat: Return markdown search-actors tools --- src/tools/get-actor-details.ts | 60 ++++--------------- src/tools/store_collection.ts | 73 +++-------------------- src/utils/actor-card.ts | 105 +++++++++++++++++++++++++++++++++ tests/integration/suite.ts | 3 +- 4 files changed, 126 insertions(+), 115 deletions(-) create mode 100644 src/utils/actor-card.ts diff --git a/src/tools/get-actor-details.ts b/src/tools/get-actor-details.ts index 99164eac..7ac16093 100644 --- a/src/tools/get-actor-details.ts +++ b/src/tools/get-actor-details.ts @@ -3,10 +3,10 @@ import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; -import { APIFY_STORE_URL, HelperTools } from '../const.js'; -import type { ExtendedPricingInfo, IActorInputSchema, InternalTool, ToolEntry } from '../types.js'; +import { HelperTools } from '../const.js'; +import type { IActorInputSchema, InternalTool, ToolEntry } from '../types.js'; +import { formatActorToActorCard } from '../utils/actor-card.js'; import { ajv } from '../utils/ajv.js'; -import { getCurrentPricingInfo, pricingInfoToString } from '../utils/pricing-info.js'; import { filterSchemaProperties, shortenProperties } from './utils.js'; const getActorDetailsToolArgsSchema = z.object({ @@ -15,31 +15,16 @@ const getActorDetailsToolArgsSchema = z.object({ .describe(`Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".`), }); -// Helper function to format categories from uppercase with underscores to proper case -function formatCategories(categories?: string[]): string[] { - if (!categories) return []; - - return categories.map((category) => { - const formatted = category - .toLowerCase() - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - // Special case for MCP server, AI, and SEO tools - return formatted.replace('Mcp Server', 'MCP Server').replace('Ai', 'AI').replace('Seo', 'SEO'); - }); -} - export const getActorDetailsTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_GET_DETAILS, - description: `Retrieve comprehensive details about an Actor using its ID or full name.\n` - + `This tool provides the Actor's title, description, URL, documentation (README), input schema, categories, pricing, and usage statistics.\n` - + `Specify the Actor name in the format "username/name" (e.g., "apify/rag-web-browser").\n` - + `The response is formatted in markdown and should be rendered as-is.\n` + description: `Get detailed information about an Actor by its ID or full name.\n` + + `This tool returns title, description, URL, README (Actor's documentation), input schema, and usage statistics. \n` + + `The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n` + + `Returns Actor information which must be displayed in the same format.\n` + `USAGE:\n` - + `- Use when a user requests information about an Actor, such as its details, description, input schema, or documentation.\n` + + `- Use when user asks about an Actor its details, description, input schema, etc.\n` + `EXAMPLES:\n` + `- user_input: How to use apify/rag-web-browser\n` + `- user_input: What is the input schema for apify/rag-web-browser`, @@ -69,35 +54,14 @@ export const getActorDetailsTool: ToolEntry = { inputSchema.properties = filterSchemaProperties(inputSchema.properties); inputSchema.properties = shortenProperties(inputSchema.properties); - const currentPricingInfo = getCurrentPricingInfo(actorInfo.pricingInfos || [], new Date()); - - // Format categories for display - const formattedCategories = formatCategories(actorInfo.categories); - - // Note: In the public API, we are missing maintainedByApify property, so we cannot use it here. - // Note: Actor rating is not in public API, we need to add it (actorUtils.getActorReviewRatingNumber(actorId)) - const actorFullName = `${actorInfo.username}/${actorInfo.name}`; - const markdownLines = [ - `Actor details (always present Actor information in this format, always include URL):\n`, - `# [${actorInfo.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`, - `**Developed by:** ${actorInfo.username} Maintained by ${actorInfo.username === 'apify' ? '(Apify)' : '(community)'}`, - `**Description:** ${actorInfo.description || 'No description provided.'}`, - `**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`, - `**Pricing:** ${pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null))}`, - `**Stats:** ${actorInfo.stats.totalUsers.toLocaleString()} total users, ${actorInfo.stats.totalUsers30Days.toLocaleString()} monthly users`, - `Last modified: ${actorInfo.modifiedAt.toISOString()}`, - ]; - if (actorInfo.isDeprecated) { - markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.'); - } - const actorCard = markdownLines.join('\n'); + // Use the actor formatter to get the main actor details + const actorCard = formatActorToActorCard(actorInfo); return { content: [ { type: 'text', text: actorCard }, - // LLM properly format Actor card, if README and input schema are separate text blocks - { type: 'text', text: `**README**:\n\n${buildInfo.actorDefinition.readme || 'No README provided.'}` }, - { type: 'text', text: `**Input Schema**:\n\n${JSON.stringify(inputSchema, null, 0)}` }, + { type: 'text', text: `**README:**\n${buildInfo.actorDefinition.readme || 'No README provided.'}` }, + { type: 'text', text: `**Input Schema:**\n${JSON.stringify(inputSchema, null, 0)}` }, ], }; }, diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 63402594..a6b3f9ef 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -5,8 +5,8 @@ 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, ExtendedPricingInfo, HelperTool, ToolEntry } from '../types.js'; -import { pricingInfoToString } from '../utils/pricing-info.js'; +import type { ActorPricingModel, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js'; +import { formatActorsListToActorCard } from '../utils/actor-card.js'; export async function searchActorsByKeywords( search: string, @@ -42,31 +42,6 @@ Do not use complex queries, AND/OR operators, or other advanced syntax, as this .describe('Filter the results by the specified category.'), }); -export interface ISearchActorsResult { - total: number; - actors: { - actorFullName: string; - - categories?: string[]; - description: string; - - actorRating: string; // We convert the star (out of 5) rating into a string representation (e.g., "4.5 out of 5") - bookmarkCount: string; // We convert the bookmark count into a string representation (e.g., "100 users bookmarked this Actor") - - pricingInfo: string; // We convert the pricing info into a string representation - - usageStatistics: { - totalUsers: { - allTime: number; - last7Days: number; - last30Days: number; - last90Days: number; - }; - failedRunsInLast30Days: number | string; // string for 'unknown' case - } - }[]; -} - /** * Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors), * unless the actor's ID is present in the user's rented actor IDs list. @@ -117,45 +92,13 @@ export const searchActors: ToolEntry = { parsed.offset, ); actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); - - const result: ISearchActorsResult = { - total: actors.length, - actors: actors.map((actor) => { - return { - actorFullName: `${actor.username}/${actor.name}`, - - categories: actor.categories, - description: actor.description || 'No description provided.', - - actorRating: actor.actorReviewRating - ? `${actor.actorReviewRating.toFixed(2)} out of 5` - : 'unknown', - bookmarkCount: actor.bookmarkCount - ? `${actor.bookmarkCount} users have bookmarked this Actor` - : 'unknown', - - pricingInfo: pricingInfoToString(actor.currentPricingInfo as ExtendedPricingInfo), - - usageStatistics: { - totalUsers: { - allTime: actor.stats.totalUsers, - last7Days: actor.stats.totalUsers7Days, - last30Days: actor.stats.totalUsers30Days, - last90Days: actor.stats.totalUsers90Days, - }, - failedRunsInLast30Days: ( - 'publicActorRunStats30Days' in actor.stats && 'FAILED' in (actor.stats.publicActorRunStats30Days as object) - ) ? (actor.stats.publicActorRunStats30Days as { FAILED: number }).FAILED : 'unknown', - }, - }; - }), - }; - + const actorCards = formatActorsListToActorCard(actors); return { - content: [{ - type: 'text', - text: JSON.stringify(result), - }], + content: [ + { type: 'text', text: `**Search query:** ${parsed.search}` }, + { type: 'text', text: `**Number of Actors found:** ${actorCards.length}` }, + { type: 'text', text: actorCards.join('\n\n') }, + ], }; }, } as HelperTool, diff --git a/src/utils/actor-card.ts b/src/utils/actor-card.ts new file mode 100644 index 00000000..6457f9e1 --- /dev/null +++ b/src/utils/actor-card.ts @@ -0,0 +1,105 @@ +import type { Actor } from 'apify-client'; + +import { APIFY_STORE_URL } from '../const.js'; +import type { ExtendedActorStoreList, ExtendedPricingInfo } from '../types.js'; +import { getCurrentPricingInfo, pricingInfoToString } from './pricing-info.js'; + +// Helper function to format categories from uppercase with underscores to proper case +function formatCategories(categories?: string[]): string[] { + if (!categories) return []; + + return categories.map((category) => { + const formatted = category + .toLowerCase() + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + // Special case for MCP server, AI, and SEO tools + return formatted.replace('Mcp Server', 'MCP Server').replace('Ai', 'AI').replace('Seo', 'SEO'); + }); +} + +/** + * Formats Actor details into an Actor card (Actor markdown representation). + * @param actor - Actor information from the API + * @returns Formatted actor card + */ +export function formatActorToActorCard( + actor: Actor | ExtendedActorStoreList, +): string { + // Format categories for display + const formattedCategories = formatCategories('categories' in actor ? actor.categories : undefined); + + // Get pricing info + let pricingInfo: string; + if ('currentPricingInfo' in actor) { + // ActorStoreList has currentPricingInfo + pricingInfo = pricingInfoToString(actor.currentPricingInfo as ExtendedPricingInfo); + } else { + // Actor has pricingInfos array + const currentPricingInfo = getCurrentPricingInfo(actor.pricingInfos || [], new Date()); + pricingInfo = pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null)); + } + + const actorFullName = `${actor.username}/${actor.name}`; + + // Build the markdown lines + const markdownLines = [ + `# [${actor.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`, + `**Developed by:** ${actor.username} ${actor.username === 'apify' ? '(Apify)' : '(community)'}`, + `**Description:** ${actor.description || 'No description provided.'}`, + `**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`, + `**Pricing:** ${pricingInfo}`, + ]; + + // Add stats - handle different stat structures + if ('stats' in actor) { + const { stats } = actor; + const statsParts = []; + + if ('totalUsers' in stats && 'totalUsers30Days' in stats) { + // Both Actor and ActorStoreList have the same stats structure + statsParts.push(`${stats.totalUsers.toLocaleString()} total users, ${stats.totalUsers30Days.toLocaleString()} monthly users`); + } + + // Add bookmark count if available (ActorStoreList only) + if ('bookmarkCount' in actor && actor.bookmarkCount) { + statsParts.push(`${actor.bookmarkCount} bookmarks`); + } + + if (statsParts.length > 0) { + markdownLines.push(`**Stats:** ${statsParts.join(', ')}`); + } + } + + // Add rating if available (ActorStoreList only) + if ('actorReviewRating' in actor && actor.actorReviewRating) { + markdownLines.push(`**Rating:** ${actor.actorReviewRating.toFixed(2)} out of 5`); + } + + // Add modification date if available + if ('modifiedAt' in actor) { + markdownLines.push(`**Last modified:** ${actor.modifiedAt.toISOString()}`); + } + + // Add deprecation warning if applicable + if ('isDeprecated' in actor && actor.isDeprecated) { + markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.'); + } + return markdownLines.join('\n'); +} + +/** + * Formats a list of Actors into Actor cards + * @param actors - Array of Actor information + * @returns Formatted markdown string + */ +export function formatActorsListToActorCard(actors: (Actor | ExtendedActorStoreList)[]): string[] { + if (actors.length === 0) { + return []; + } + return actors.map((actor, index) => { + const card = formatActorToActorCard(actor); + return `${index + 1}. ${card}`; + }); +} diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index d03c80d2..220033d3 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -6,7 +6,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { defaults, HelperTools } from '../../src/const.js'; import { latestNewsOnTopicPrompt } from '../../src/prompts/latest-news-on-topic.js'; import { addRemoveTools, defaultTools, toolCategories, toolCategoriesEnabledByDefault } from '../../src/tools/index.js'; -import type { ISearchActorsResult } from '../../src/tools/store_collection.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; import type { ToolCategory } from '../../src/types.js'; import { ACTOR_MCP_SERVER_ACTOR_NAME, ACTOR_PYTHON_EXAMPLE, DEFAULT_ACTOR_NAMES, DEFAULT_TOOL_NAMES } from '../const.js'; @@ -282,7 +281,7 @@ export function createIntegrationTestsSuite( }); const content = result.content as {text: string}[]; expect(content.length).toBe(1); - const resultJson = JSON.parse(content[0].text) as ISearchActorsResult; + const resultJson = JSON.parse(content[0].text); const { actors } = resultJson; expect(actors.length).toBe(resultJson.total); expect(actors.length).toBeGreaterThan(0); From 6768391024e07f5604cf92253257a034a7b35bf7 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 15:29:04 +0200 Subject: [PATCH 04/10] feat: Improve search tool description --- src/tools/get-actor-details.ts | 4 ++-- src/tools/store_collection.ts | 25 ++++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tools/get-actor-details.ts b/src/tools/get-actor-details.ts index 7ac16093..6b74d298 100644 --- a/src/tools/get-actor-details.ts +++ b/src/tools/get-actor-details.ts @@ -22,7 +22,7 @@ export const getActorDetailsTool: ToolEntry = { description: `Get detailed information about an Actor by its ID or full name.\n` + `This tool returns title, description, URL, README (Actor's documentation), input schema, and usage statistics. \n` + `The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n` - + `Returns Actor information which must be displayed in the same format.\n` + + `Present Actor information in user-friendly format as an Actor card.\n` + `USAGE:\n` + `- Use when user asks about an Actor its details, description, input schema, etc.\n` + `EXAMPLES:\n` @@ -59,7 +59,7 @@ export const getActorDetailsTool: ToolEntry = { return { content: [ - { type: 'text', text: actorCard }, + { type: 'text', text: `**Actor card**:\n${actorCard}` }, { type: 'text', text: `**README:**\n${buildInfo.actorDefinition.readme || 'No README provided.'}` }, { type: 'text', text: `**Input Schema:**\n${JSON.stringify(inputSchema, null, 0)}` }, ], diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index a6b3f9ef..7ec4ac9b 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -72,14 +72,21 @@ export const searchActors: ToolEntry = { type: 'internal', tool: { name: HelperTools.STORE_SEARCH, - description: `Discover available Actors or MCP servers (which are also considered Actors in the context of Apify) in the Apify Store.\n` - + 'This tool uses full-text search, so you MUST use simple space-separated keywords, such as "web scraping", ' - + '"data extraction", or "playwright browser mcp".\n' - + 'This tool returns a list of Actors with basic information, including descriptions, pricing models, usage statistics, and user ratings.\n' - + 'Prefer Actors with more users, stars, and runs.\n' - + 'You may need to use this tool several times to find the right Actor.\n' - + 'Limit the number of results returned, but ensure that relevant results are included.\n' - + 'This is not a general search tool; it is designed specifically to search for Actors in the Apify Store.', + description: `Search for Actors or Model Context Protocol (MCP) servers in the Apify Store using keywords.\n` + + `This tool returns a list of Actors with title, description, pricing model, usage statistics, and user ratings.\n` + + `Use simple space-separated keywords for best results, such as "web scraping", "data extraction", or "playwright browser mcp".\n` + + `You may need to use this tool several times to find the right Actor.\n` + + `Limit the number of results returned, but ensure that relevant results are included.\n` + + `Always present the results in a user-friendly format as an Actor cards.\n` + + `USAGE:\n` + + `- Use when user wants to find Actors for a specific task or technology\n` + + `- Use when user asks about available Actors in the Apify Store\n` + + `- Use when user needs to discover MCP servers or automation tools\n` + + `EXAMPLES:\n` + + `- user_input: Find Actors for web scraping\n` + + `- user_input: Search for MCP servers\n` + + `- user_input: What Actors are available for data extraction\n` + + `- user_input: Show me Actors that use Playwright`, inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { @@ -97,7 +104,7 @@ export const searchActors: ToolEntry = { content: [ { type: 'text', text: `**Search query:** ${parsed.search}` }, { type: 'text', text: `**Number of Actors found:** ${actorCards.length}` }, - { type: 'text', text: actorCards.join('\n\n') }, + { type: 'text', text: `**Actor cards:**\n${actorCards.join('\n\n')}` }, ], }; }, From 87e691045fa8fedd911f9bd1e9120228a2c30d1d Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 15:31:17 +0200 Subject: [PATCH 05/10] fix: remove unused tool --- src/tools/actor.ts | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 98af9adb..817b9610 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -16,7 +16,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, InternalTool, ToolEntry } from '../types.js'; +import type { ActorDefinitionStorage, ActorInfo, ToolEntry } from '../types.js'; import { getActorDefinitionStorageFieldNames } from '../utils/actor.js'; import { getValuesByDotKeys } from '../utils/generic.js'; import type { ProgressTracker } from '../utils/progress.js'; @@ -257,41 +257,6 @@ export async function getActorsAsTools( return [...normalTools, ...mcpServerTools]; } -const getActorArgs = z.object({ - actorId: z.string() - .min(1) - .describe('Actor ID or a tilde-separated owner\'s username and Actor name.'), -}); - -/** - * https://docs.apify.com/api/v2/act-get - */ -export const getActor: ToolEntry = { - type: 'internal', - tool: { - name: HelperTools.ACTOR_GET, - actorFullName: HelperTools.ACTOR_GET, - description: 'Gets an object that contains all the details about a specific Actor.' - + 'Actor basic information (ID, name, owner, description)' - + 'Statistics (number of runs, users, etc.)' - + 'Available versions, and configuration details' - + 'Use Actor ID or Actor full name, separated by tilde username~name.', - inputSchema: zodToJsonSchema(getActorArgs), - ajvValidate: ajv.compile(zodToJsonSchema(getActorArgs)), - call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; - const { actorId } = getActorArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); - // Get Actor - contains a lot of irrelevant information - const actor = await client.actor(actorId).get(); - if (!actor) { - return { content: [{ type: 'text', text: `Actor '${actorId}' not found.` }] }; - } - return { content: [{ type: 'text', text: JSON.stringify(actor) }] }; - }, - } as InternalTool, -}; - const callActorArgs = z.object({ actor: z.string() .describe('The name of the Actor to call. For example, "apify/instagram-scraper".'), From e19307943c33eb33c9ed8dd5b26c0264e2cec648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Spilka?= Date: Tue, 29 Jul 2025 16:09:16 +0200 Subject: [PATCH 06/10] Update src/utils/actor-card.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/actor-card.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/actor-card.ts b/src/utils/actor-card.ts index 6457f9e1..d6aee9a6 100644 --- a/src/utils/actor-card.ts +++ b/src/utils/actor-card.ts @@ -98,8 +98,8 @@ export function formatActorsListToActorCard(actors: (Actor | ExtendedActorStoreL if (actors.length === 0) { return []; } - return actors.map((actor, index) => { + return actors.map((actor) => { const card = formatActorToActorCard(actor); - return `${index + 1}. ${card}`; + return `- ${card}`; }); } From d306e57b31d699a501671366bcce6d3549a1062a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Spilka?= Date: Tue, 29 Jul 2025 16:09:49 +0200 Subject: [PATCH 07/10] Update src/tools/store_collection.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tools/store_collection.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 7ec4ac9b..cb980861 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -102,9 +102,12 @@ export const searchActors: ToolEntry = { const actorCards = formatActorsListToActorCard(actors); return { content: [ - { type: 'text', text: `**Search query:** ${parsed.search}` }, - { type: 'text', text: `**Number of Actors found:** ${actorCards.length}` }, - { type: 'text', text: `**Actor cards:**\n${actorCards.join('\n\n')}` }, + { + type: 'text', + text: `**Search query:** ${parsed.search}\n\n` + + `**Number of Actors found:** ${actorCards.length}\n\n` + + `**Actor cards:**\n${actorCards.join('\n\n')}` + }, ], }; }, From f6bca50c556bf21a19e806925a17561162e270a0 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 16:11:16 +0200 Subject: [PATCH 08/10] fix: lint issues --- src/tools/store_collection.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index cb980861..e0dd0aa5 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -102,11 +102,11 @@ export const searchActors: ToolEntry = { const actorCards = formatActorsListToActorCard(actors); return { content: [ - { - type: 'text', - text: `**Search query:** ${parsed.search}\n\n` + - `**Number of Actors found:** ${actorCards.length}\n\n` + - `**Actor cards:**\n${actorCards.join('\n\n')}` + { + type: 'text', + text: `**Search query:** ${parsed.search}\n\n` + + `**Number of Actors found:** ${actorCards.length}\n\n` + + `**Actor cards:**\n${actorCards.join('\n\n')}`, }, ], }; From 0d09c4de7d26b5067fd737a8d8960f64e7985f25 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 16:59:35 +0200 Subject: [PATCH 09/10] fix: tool description --- src/tools/store_collection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index e0dd0aa5..7446cdd6 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -74,10 +74,10 @@ export const searchActors: ToolEntry = { name: HelperTools.STORE_SEARCH, description: `Search for Actors or Model Context Protocol (MCP) servers in the Apify Store using keywords.\n` + `This tool returns a list of Actors with title, description, pricing model, usage statistics, and user ratings.\n` - + `Use simple space-separated keywords for best results, such as "web scraping", "data extraction", or "playwright browser mcp".\n` + + `Use simple space-separated keywords for best results, such as "web scraping", "data extraction", or "playwright mcp".\n` + `You may need to use this tool several times to find the right Actor.\n` + `Limit the number of results returned, but ensure that relevant results are included.\n` - + `Always present the results in a user-friendly format as an Actor cards.\n` + + `Always present the results in a user-friendly format as an Actor cards.\n\n` + `USAGE:\n` + `- Use when user wants to find Actors for a specific task or technology\n` + `- Use when user asks about available Actors in the Apify Store\n` From d11f7884f7609f7707160e20ebb11579db46c311 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Tue, 29 Jul 2025 22:08:21 +0200 Subject: [PATCH 10/10] fix: add run succeeded to Actor card --- src/types.ts | 7 ------- src/utils/actor-card.ts | 12 ++++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/types.ts b/src/types.ts index d0114728..599627d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,13 +157,6 @@ export interface ToolEntry { tool: ActorTool | HelperTool | ActorMcpTool; } -// ActorStoreList for actor-search tool -export interface ActorStats { - totalRuns: number; - totalUsers30Days: number; - publicActorRunStats30Days: unknown; -} - /** * Price for a single event in a specific tier. */ diff --git a/src/utils/actor-card.ts b/src/utils/actor-card.ts index d6aee9a6..2a699979 100644 --- a/src/utils/actor-card.ts +++ b/src/utils/actor-card.ts @@ -62,6 +62,18 @@ export function formatActorToActorCard( statsParts.push(`${stats.totalUsers.toLocaleString()} total users, ${stats.totalUsers30Days.toLocaleString()} monthly users`); } + // Add success rate for last 30 days if available + if ('publicActorRunStats30Days' in stats && stats.publicActorRunStats30Days) { + const runStats = stats.publicActorRunStats30Days as { + SUCCEEDED: number; + TOTAL: number; + }; + if (runStats.TOTAL > 0) { + const successRate = ((runStats.SUCCEEDED / runStats.TOTAL) * 100).toFixed(1); + statsParts.push(`Runs succeeded: ${successRate}%`); + } + } + // Add bookmark count if available (ActorStoreList only) if ('bookmarkCount' in actor && actor.bookmarkCount) { statsParts.push(`${actor.bookmarkCount} bookmarks`);