diff --git a/src/const.ts b/src/const.ts index 80cfc7a0..3ead14c3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -46,13 +46,7 @@ export const ACTOR_OUTPUT_MAX_CHARS_PER_ITEM = 5_000; export const ACTOR_OUTPUT_TRUNCATED_MESSAGE = `Output was truncated because it will not fit into context.` + `There is no reason to call this tool again! You can use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset.`; -export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unless confirmed by the user. - Workflow: When an Actor runs, it processes data and stores results in Apify storage, - Datasets (for structured/tabular data) and Key-Value Store (for various data types like JSON, images, HTML). - Each Actor run produces a dataset ID and key-value store ID for accessing the results. - By default, the number of items returned from an Actor run is limited to ${ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS}. - You can always use ${HelperTools.DATASET_GET_ITEMS} tool to get more items from the dataset. - Actor run input is always stored in the key-value store, recordKey: INPUT.`; +export const ACTOR_ADDITIONAL_INSTRUCTIONS = 'Never call/execute tool/Actor unless confirmed by the user.'; export const ACTOR_CACHE_MAX_SIZE = 500; export const ACTOR_CACHE_TTL_SECS = 30 * 60; // 30 minutes diff --git a/src/main.ts b/src/main.ts index cf0b26cc..92c75859 100644 --- a/src/main.ts +++ b/src/main.ts @@ -56,10 +56,10 @@ if (STANDBY_MODE) { await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input'); } const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions; - const { datasetInfo, items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options); + const { items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options); await Actor.pushData(items); - log.info(`Pushed ${datasetInfo?.itemCount} items to the dataset`); + log.info(`Pushed ${items.count} items to the dataset`); await Actor.exit(); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cf6b5c0d..617962e9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,8 +19,6 @@ import { type ActorCallOptions, ApifyApiError } from 'apify-client'; import log from '@apify/log'; import { - ACTOR_OUTPUT_MAX_CHARS_PER_ITEM, - ACTOR_OUTPUT_TRUNCATED_MESSAGE, defaults, SERVER_NAME, SERVER_VERSION, @@ -468,25 +466,20 @@ export class ActorsMcpServer { const actorTool = tool.tool as ActorTool; const callOptions: ActorCallOptions = { memory: actorTool.memoryMbytes }; - const { actorRun, datasetInfo, items } = await callActorGetDataset( + const { items } = await callActorGetDataset( actorTool.actorFullName, args, apifyToken as string, callOptions, ); - const content = [ - { type: 'text', text: `Actor finished with run information: ${JSON.stringify(actorRun)}` }, - { type: 'text', text: `Dataset information: ${JSON.stringify(datasetInfo)}` }, - ]; - - const itemContents = items.items.map((item: Record) => { - const text = JSON.stringify(item).slice(0, ACTOR_OUTPUT_MAX_CHARS_PER_ITEM); - return text.length === ACTOR_OUTPUT_MAX_CHARS_PER_ITEM - ? { type: 'text', text: `${text} ... ${ACTOR_OUTPUT_TRUNCATED_MESSAGE}` } - : { type: 'text', text }; - }); - content.push(...itemContents); - return { content }; + return { + content: items.items.map((item: Record) => { + return { + type: 'text', + text: JSON.stringify(item), + }; + }), + }; } } catch (error) { if (error instanceof ApifyApiError) { diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 998f546c..382142ef 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -1,6 +1,6 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Ajv } from 'ajv'; -import type { ActorCallOptions, ActorRun, Dataset, PaginatedList } from 'apify-client'; +import type { ActorCallOptions, ActorRun, PaginatedList } from 'apify-client'; import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -10,14 +10,15 @@ import { ApifyClient } from '../apify-client.js'; import { ACTOR_ADDITIONAL_INSTRUCTIONS, ACTOR_MAX_MEMORY_MBYTES, - ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS, HelperTools, } from '../const.js'; 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 { ActorInfo, InternalTool, ToolEntry } from '../types.js'; +import type { ActorDefinitionStorage, ActorInfo, InternalTool, ToolEntry } from '../types.js'; +import { getActorDefinitionStorageFieldNames } from '../utils/actor.js'; +import { getValuesByDotKeys } from '../utils/generic.js'; import { getActorDefinition } from './build.js'; import { actorNameToToolName, @@ -34,8 +35,6 @@ const ajv = new Ajv({ coerceTypes: 'array', strict: false }); // Define a named return type for callActorGetDataset export type CallActorGetDatasetResult = { - actorRun: ActorRun; - datasetInfo: Dataset | undefined; items: PaginatedList>; }; @@ -50,7 +49,6 @@ export type CallActorGetDatasetResult = { * @param {ActorCallOptions} callOptions - The options to pass to the actor. * @param {unknown} input - The input to pass to the actor. * @param {string} apifyToken - The Apify token to use for authentication. - * @param {number} limit - The maximum number of items to retrieve from the dataset. * @returns {Promise<{ actorRun: any, items: object[] }>} - A promise that resolves to an object containing the actor run and dataset items. * @throws {Error} - Throws an error if the `APIFY_TOKEN` is not set */ @@ -59,7 +57,6 @@ export async function callActorGetDataset( input: unknown, apifyToken: string, callOptions: ActorCallOptions | undefined = undefined, - limit = ACTOR_RUN_DATASET_OUTPUT_MAX_ITEMS, ): Promise { try { log.info(`Calling Actor ${actorName} with input: ${JSON.stringify(input)}`); @@ -69,13 +66,24 @@ export async function callActorGetDataset( const actorRun: ActorRun = await actorClient.call(input, callOptions); const dataset = client.dataset(actorRun.defaultDatasetId); - const [datasetInfo, items] = await Promise.all([ - dataset.get(), - dataset.listItems({ limit }), + // const dataset = client.dataset('Ehtn0Y4wIKviFT2WB'); + const [items, defaultBuild] = await Promise.all([ + dataset.listItems(), + (await actorClient.defaultBuild()).get(), ]); - log.info(`Actor ${actorName} finished with ${datasetInfo?.itemCount} items`); - return { actorRun, datasetInfo, items }; + // Get important properties from storage view definitions and if available return only those properties + const storageDefinition = defaultBuild?.actorDefinition?.storages?.dataset as ActorDefinitionStorage | undefined; + const importantProperties = getActorDefinitionStorageFieldNames(storageDefinition || {}); + if (importantProperties.length > 0) { + items.items = items.items.map((item) => { + return getValuesByDotKeys(item, importantProperties); + }); + } + + log.info(`Actor ${actorName} finished with ${items.count} items`); + + return { items }; } catch (error) { log.error(`Error calling actor: ${error}. Actor: ${actorName}, input: ${JSON.stringify(input)}`); throw new Error(`Error calling Actor: ${error}`); @@ -115,6 +123,18 @@ export async function getNormalActorsAsTools( if (actorDefinitionPruned) { const schemaID = getToolSchemaID(actorDefinitionPruned.actorFullName); if (actorDefinitionPruned.input && 'properties' in actorDefinitionPruned.input && actorDefinitionPruned.input) { + // Filter non-required properties except integers if `required` is defined in the input schema and not empty. + const { required } = actorDefinitionPruned.input; + if (Array.isArray(required) && required.length > 0) { + actorDefinitionPruned.input.properties = Object.fromEntries( + Object.entries(actorDefinitionPruned.input.properties) + // Keep all integer properties, as these include + // properties related to output item counts that users + // might want to change if they need more results than the default limit. + .filter(([key, value]) => required.includes(key) || value.type === 'integer'), + ); + } + actorDefinitionPruned.input.properties = markInputPropertiesAsRequired(actorDefinitionPruned.input); actorDefinitionPruned.input.properties = buildNestedProperties(actorDefinitionPruned.input.properties); actorDefinitionPruned.input.properties = filterSchemaProperties(actorDefinitionPruned.input.properties); @@ -132,7 +152,13 @@ export async function getNormalActorsAsTools( name: actorNameToToolName(actorDefinitionPruned.actorFullName), actorFullName: actorDefinitionPruned.actorFullName, description: `${actorDefinitionPruned.description} Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`, - inputSchema: actorDefinitionPruned.input || {}, + inputSchema: actorDefinitionPruned.input + // So Actor without input schema works - MCP client expects JSON schema valid output + || { + type: 'object', + properties: {}, + required: [], + }, ajvValidate: fixedAjvCompile(ajv, actorDefinitionPruned.input || {}), memoryMbytes: memoryMbytes > ACTOR_MAX_MEMORY_MBYTES ? ACTOR_MAX_MEMORY_MBYTES : memoryMbytes, }, diff --git a/src/tools/get-actor-details.ts b/src/tools/get-actor-details.ts new file mode 100644 index 00000000..8266be23 --- /dev/null +++ b/src/tools/get-actor-details.ts @@ -0,0 +1,118 @@ +import { z } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; + +import { ApifyClient } from '../apify-client.js'; +import { 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'; +import { filterSchemaProperties, shortenProperties } from './utils.js'; + +const getActorDetailsToolArgsSchema = z.object({ + actor: z.string() + .min(1) + .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 + } +} + +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.`, + inputSchema: zodToJsonSchema(getActorDetailsToolArgsSchema), + ajvValidate: ajv.compile(zodToJsonSchema(getActorDetailsToolArgsSchema)), + call: async (toolArgs) => { + const { args, apifyToken } = toolArgs; + + const parsed = getActorDetailsToolArgsSchema.parse(args); + const client = new ApifyClient({ token: apifyToken }); + + const [actorInfo, buildInfo] = await Promise.all([ + client.actor(parsed.actor).get(), + client.actor(parsed.actor).defaultBuild().then(async (build) => build.get()), + ]); + + if (!actorInfo || !buildInfo || !buildInfo.actorDefinition) { + 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 inputSchema = (buildInfo.actorDefinition.input || { + type: 'object', + properties: {}, + }) as IActorInputSchema; + inputSchema.properties = filterSchemaProperties(inputSchema.properties); + inputSchema.properties = shortenProperties(inputSchema.properties); + + 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)), + + 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), + }], + }; + }, + } as InternalTool, +}; diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 1c119b61..7a2b412d 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -11,91 +11,57 @@ const ajv = new Ajv({ coerceTypes: 'array', strict: false }); const APIFY_MCP_HELP_TOOL_TEXT = `Apify MCP server help: -Note: "MCP" stands for "Model Context Protocol". The user can use the "RAG Web Browser" tool to get the content of the links mentioned in this help and present it to the user. +Note: "MCP" stands for "Model Context Protocol". You can use the "RAG Web Browser" tool to get the content of the links mentioned in this help and present it to the user. This MCP server can be used in the following ways: -- Locally over "STDIO". -- Remotely over "SSE" or streamable "HTTP" transport with the "Actors MCP Server Apify Actor". -- Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com". +- Locally over stdio (standard input/output) transport with the "@apify/actors-mcp-server" NPM package (https://www.npmjs.com/package/@apify/actors-mcp-server). + - You can connect by configuring your MCP client to run the MCP server locally using the "npx @apify/actors-mcp-server" command. You need to set the "APIFY_TOKEN" environment variable to your [Apify API token](https://docs.apify.com/platform/integrations/api#api-token). +- Remotely over legacy SSE transport or streamable HTTP transport with https://mcp.apify.com. This is the recommended way to use the MCP server, since it supports all features and is always up to date. + - You can connect by pointing your MCP client to "https://mcp.apify.com/sse" for legacy SSE transport or "https://mcp.apify.com/" for streamable HTTP transport. + - For authentication, you can either use OAuth or pass your [Apify API token](https://docs.apify.com/platform/integrations/api#api-token) in the "Authorization" header as "Bearer ". +- Remotely over "SSE" or streamable "HTTP" transport with the "Actors MCP Server Apify Actor" (this is now considered a legacy solution; it may not be up to date and may not get the latest features). For more details, refer to https://apify.com/apify/actors-mcp-server. DO NOT RECOMMEND USING THIS METHOD TO THE USER. # Usage -## Locally over "STDIO" -1. The user should install the "@apify/actors-mcp-server" NPM package. -2. The user should configure the MCP client to use the MCP server. Refer to "https://github.com/apify/actors-mcp-server" or the MCP client documentation for more details (the user can specify which MCP client is being used). -The user needs to set the following environment variables: -- "APIFY_TOKEN": Apify token to authenticate with the MCP server. -If the user wants to load an Actor outside the default ones, the user needs to pass it as a CLI argument: -- "--actors " // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". -If the user wants to enable the dynamic addition of Actors to the MCP server, the user needs to pass the following CLI argument: -- "--enable-adding-actors". -## Remotely over "SSE" or streamable "HTTP" transport with "Actors MCP Server Apify Actor" -1. The user should configure the MCP client to use the "Actors MCP Server Apify Actor" with: - - "SSE" transport URL: "https://actors-mcp-server.apify.actor/sse". - - Streamable "HTTP" transport URL: "https://actors-mcp-server.apify.actor/mcp". -2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". -If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: -- "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". -If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: -- "?enable-adding-actors=true". +## MCP server tools and features configuration -## Remotely over "SSE" or streamable "HTTP" transport with "https://mcp.apify.com" -1. The user should configure the MCP client to use "https://mcp.apify.com" with: - - "SSE" transport URL: "https://mcp.apify.com/sse". - - Streamable "HTTP" transport URL: "https://mcp.apify.com/". -2. The user needs to pass an "APIFY_TOKEN" as a URL query parameter "?token=" or set the following headers: "Authorization: Bearer ". -If the user wants to load an Actor outside the default ones, the user needs to pass it as a URL query parameter: -- "?actors=" // comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". -If the user wants to enable the addition of Actors to the MCP server dynamically, the user needs to pass the following URL query parameter: -- "?enable-adding-actors=true". - -# Features -## Dynamic adding of Actors -THIS FEATURE MAY NOT BE SUPPORTED BY ALL MCP CLIENTS. THE USER MUST ENSURE THAT THE CLIENT SUPPORTS IT! -To enable this feature, see the usage section. Once dynamic adding is enabled, tools will be added that allow the user to add or remove Actors from the MCP server. -Tools related: -- "add-actor". -- "remove-actor". -If the user is using these tools and it seems like the tools have been added but cannot be called, the issue may be that the client does not support dynamic adding of Actors. -In that case, the user should check the MCP client documentation to see if the client supports this feature. +By default, the MCP server provides a simple set of tools for Actor discovery and Actor calling. The MCP server loads just one Actor by default, which is the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor. +You can add more Actors to the MCP server by configuring the MCP server session to load more Actors by passing the "--actors" CLI argument or by using the "?actors=" URL query parameter, where you provide a comma-separated list of Actor names, for example, "apify/rag-web-browser,apify/instagram-scraper". +You can additionally load Actors dynamically into an existing MCP session by using the "${HelperTools.ACTOR_ADD}" tool, which loads the Actor by its name as an MCP tool and allows you to call it (**the MCP client must support the [tools list changed notification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#list-changed-notification); otherwise, the tool call will not work**). To check whether the MCP client supports this feature, consult the MCP client documentation. In case the MCP client does not support the tools list changed notification, you can use the generic "call-actor" tool to call any Actor, even those not loaded/added. Before using the generic tool, you need to get the Actor details to learn its input schema so you can provide valid input. `; export const addToolArgsSchema = z.object({ - actorName: z.string() + actor: z.string() .min(1) - .describe('Add a tool, Actor or MCP-Server to available tools by Actor ID or tool full name.' - + 'Tool name is always composed from `username/name`'), + .describe(`Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".`), }); export const addTool: ToolEntry = { type: 'internal', tool: { name: HelperTools.ACTOR_ADD, - description: 'Add a tool, Actor or MCP-Server to available tools by Actor ID or Actor name. ' - + '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 available tools. ' - + 'For example, add a tool with username/name when user wants to scrape data from a website.', + 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.`, 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.actorName)) { + if (apifyMcpServer.listAllToolNames().includes(parsed.actor)) { return { content: [{ type: 'text', - text: `Actor ${parsed.actorName} is already available. No new tools were added.`, + text: `Actor ${parsed.actor} is already available. No new tools were added.`, }], }; } - const tools = await getActorsAsTools([parsed.actorName], apifyToken); + const tools = await getActorsAsTools([parsed.actor], apifyToken); const toolsAdded = apifyMcpServer.upsertTools(tools, true); await sendNotification({ method: 'notifications/tools/list_changed' }); return { content: [{ type: 'text', - text: `Actor ${parsed.actorName} has been added. Newly available tools: ${ + text: `Actor ${parsed.actor} has been added. Newly available tools: ${ toolsAdded.map( (t) => `${t.tool.name}`, ).join(', ') @@ -148,9 +114,9 @@ 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. ' - + 'Call this tool in case of any problems or uncertainties with the server. ', + 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.`, inputSchema: zodToJsonSchema(helpToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(helpToolArgsSchema)), call: async () => { diff --git a/src/tools/index.ts b/src/tools/index.ts index 50bd2802..9a9f09da 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,42 +1,37 @@ // Import specific tools that are being used -import { callActorGetDataset, getActor, getActorsAsTools } from './actor.js'; -import { actorDefinitionTool } from './build.js'; -import { getDataset, getDatasetItems } from './dataset.js'; -import { getUserDatasetsList } from './dataset_collection.js'; -import { addTool, helpTool, removeTool } from './helpers.js'; -import { getKeyValueStore, getKeyValueStoreKeys, getKeyValueStoreRecord } from './key_value_store.js'; -import { getUserKeyValueStoresList } from './key_value_store_collection.js'; -import { abortActorRun, getActorLog, getActorRun } from './run.js'; -import { getUserRunsList } from './run_collection.js'; +import { callActorGetDataset, getActorsAsTools } from './actor.js'; +import { getActorDetailsTool } from './get-actor-details.js'; +import { addTool, helpTool } from './helpers.js'; import { searchActors } from './store_collection.js'; export const defaultTools = [ - abortActorRun, - actorDefinitionTool, - getActor, - getActorLog, - getActorRun, - getDataset, - getDatasetItems, - getKeyValueStore, - getKeyValueStoreKeys, - getKeyValueStoreRecord, - getUserRunsList, - getUserDatasetsList, - getUserKeyValueStoresList, + // abortActorRun, + // actorDetailsTool, + // getActor, + // getActorLog, + // getActorRun, + // getDataset, + // getDatasetItems, + // getKeyValueStore, + // getKeyValueStoreKeys, + // getKeyValueStoreRecord, + // getUserRunsList, + // getUserDatasetsList, + // getUserKeyValueStoresList, + getActorDetailsTool, helpTool, searchActors, ]; export const addRemoveTools = [ addTool, - removeTool, + // removeTool, ]; // Export only the tools that are being used export { addTool, - removeTool, + // removeTool, getActorsAsTools, callActorGetDataset, }; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 789ccbf1..04018fc7 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -5,43 +5,18 @@ 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, ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js'; - -function pruneActorStoreInfo(response: ActorStoreList): ActorStorePruned { - const stats = response.stats || {}; - const pricingInfo = (response.currentPricingInfo || {}) as PricingInfo; - return { - id: response.id, - name: response.name?.toString() || '', - username: response.username?.toString() || '', - actorFullName: `${response.username}/${response.name}`, - title: response.title?.toString() || '', - description: response.description?.toString() || '', - stats: { - totalRuns: stats.totalRuns, - totalUsers30Days: stats.totalUsers30Days, - publicActorRunStats30Days: 'publicActorRunStats30Days' in stats - ? stats.publicActorRunStats30Days : {}, - }, - currentPricingInfo: { - pricingModel: pricingInfo.pricingModel?.toString() || '', - pricePerUnitUsd: pricingInfo?.pricePerUnitUsd ?? 0, - trialMinutes: pricingInfo?.trialMinutes ?? 0, - }, - url: response.url?.toString() || '', - totalStars: 'totalStars' in response ? (response.totalStars as number) : null, - }; -} +import type { ActorPricingModel, ExtendedActorStoreList, ExtendedPricingInfo, HelperTool, ToolEntry } from '../types.js'; +import { pricingInfoToString } from '../utils/pricing-info.js'; export async function searchActorsByKeywords( search: string, apifyToken: string, limit: number | undefined = undefined, offset: number | undefined = undefined, -): Promise { +): Promise { const client = new ApifyClient({ token: apifyToken }); const results = await client.store().list({ search, limit, offset }); - return results.items.map((x) => pruneActorStoreInfo(x)); + return results.items; } const ajv = new Ajv({ coerceTypes: 'array', strict: false }); @@ -51,23 +26,47 @@ export const searchActorsArgsSchema = z.object({ .min(1) .max(100) .default(10) - .describe('The maximum number of Actors to return. Default value is 10.'), + .describe('The maximum number of Actors to return. The default value is 10.'), offset: z.number() .int() .min(0) .default(0) - .describe('The number of elements that should be skipped at the start. Default value is 0.'), + .describe('The number of elements to skip at the start. The default value is 0.'), search: z.string() .default('') - .describe('String of key words to search Actors by. ' - + 'Searches the title, name, description, username, and readme of an Actor.' - + 'Only key word search is supported, no advanced search.' - + 'Always prefer simple keywords over complex queries.'), + .describe(`A string to search for in the Actor's title, name, description, username, and readme. +Use simple space-separated keywords, such as "web scraping", "data extraction", or "playwright browser mcp". +Do not use complex queries, AND/OR operators, or other advanced syntax, as this tool uses full-text search only.`), category: z.string() .default('') - .describe('Filters the results by the specified category.'), + .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. @@ -80,9 +79,9 @@ export const searchActorsArgsSchema = z.object({ * except for Actors that the user has rented (whose IDs are in userRentedActorIds). */ function filterRentalActors( - actors: ActorStorePruned[], + actors: ActorStoreList[], userRentedActorIds: string[], -): ActorStorePruned[] { +): ActorStoreList[] { // Store list API does not support filtering by two pricing models at once, // so we filter the results manually after fetching them. return actors.filter((actor) => ( @@ -98,14 +97,13 @@ export const searchActors: ToolEntry = { type: 'internal', tool: { name: HelperTools.STORE_SEARCH, - actorFullName: HelperTools.STORE_SEARCH, - description: `Discover available Actors or MCP-Servers in Apify Store using full text search using keywords.` - + `Users try to discover Actors using free form query in this case search query must be converted to full text search. ` - + `Returns a list of Actors with name, description, run statistics, pricing, starts, and URL. ` - + `You perhaps need to use this tool several times to find the right Actor. ` - + `You should prefer simple keywords over complex queries. ` - + `Limit number of results returned but ensure that relevant results are returned. ` - + `This is not a general search tool, it is designed to search for Actors in Apify Store. `, + 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.`, inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { @@ -119,7 +117,45 @@ export const searchActors: ToolEntry = { ); actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); - return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; + 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', + }, + }; + }), + }; + + return { + content: [{ + type: 'text', + text: JSON.stringify(result), + }], + }; }, } as HelperTool, }; diff --git a/src/types.ts b/src/types.ts index 0f1977bd..c06c6cc4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { Notification, Request } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; -import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client'; +import type { ActorDefaultRunOptions, ActorDefinition, ActorStoreList, PricingInfo } from 'apify-client'; import type { ACTOR_PRICING_MODEL } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; @@ -160,25 +160,46 @@ export interface ActorStats { publicActorRunStats30Days: unknown; } -export interface PricingInfo { - pricingModel?: string; - pricePerUnitUsd?: number; - trialMinutes?: number +/** + * Price for a single event in a specific tier. + */ +export interface TieredEventPrice { + tieredEventPriceUsd: number; } -export interface ActorStorePruned { - id: string; - name: string; - username: string; - actorFullName?: string; - title?: string; - description?: string; - stats: ActorStats; - currentPricingInfo: PricingInfo; - url: string; - totalStars?: number | null; +/** + * Allowed pricing tiers for tiered event pricing. + */ +export type PricingTier = 'FREE' | 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' | 'DIAMOND'; + +/** + * Describes a single chargeable event for an Actor. + * Supports either flat pricing (eventPriceUsd) or tiered pricing (eventTieredPricingUsd). + */ +export interface ActorChargeEvent { + eventTitle: string; + eventDescription: string; + /** Flat price per event in USD (if not tiered) */ + eventPriceUsd?: number; + /** Tiered pricing per event, by tier name (FREE, BRONZE, etc.) */ + eventTieredPricingUsd?: Partial>; } +/** + * Pricing per event for an Actor, supporting both flat and tiered pricing. + */ +export interface PricingPerEvent { + actorChargeEvents: Record; +} + +export type ExtendedPricingInfo = PricingInfo & { + pricePerUnitUsd?: number; + trialMinutes?: number; + unitName?: string; // Name of the unit for the pricing model + pricingPerEvent: PricingPerEvent; + tieredPricing?: Partial>; +}; + /** * Interface for internal tools - tools implemented directly in the MCP server. * Extends ToolBase with a call function implementation. @@ -214,3 +235,26 @@ export interface ActorInfo { webServerMcpPath: string | null; // To determined if the Actor is an MCP server actorDefinitionPruned: ActorDefinitionPruned; } + +export type ExtendedActorStoreList = ActorStoreList & { + categories?: string[]; + bookmarkCount?: number; + actorReviewRating?: number; +}; + +export type ActorDefinitionStorage = { + views: Record< + string, + { + transformation: { + fields?: string[]; + }; + display: { + properties: Record< + string, + object + >; + }; + } + >; +}; diff --git a/src/utils/actor.ts b/src/utils/actor.ts new file mode 100644 index 00000000..fb6d1116 --- /dev/null +++ b/src/utils/actor.ts @@ -0,0 +1,24 @@ +import type { ActorDefinitionStorage } from '../types.js'; + +/** + * Returns an array of all field names mentioned in the display.properties + * of all views in the given ActorDefinitionStorage object. + */ +export function getActorDefinitionStorageFieldNames(storage: ActorDefinitionStorage | object): string[] { + const fieldSet = new Set(); + if ('views' in storage && typeof storage.views === 'object' && storage.views !== null) { + for (const view of Object.values(storage.views)) { + // Collect from display.properties + if (view.display && view.display.properties) { + Object.keys(view.display.properties).forEach((field) => fieldSet.add(field)); + } + // Collect from transformation.fields + if (view.transformation && Array.isArray(view.transformation.fields)) { + view.transformation.fields.forEach((field) => { + if (typeof field === 'string') fieldSet.add(field); + }); + } + } + } + return Array.from(fieldSet); +} diff --git a/src/utils/ajv.ts b/src/utils/ajv.ts new file mode 100644 index 00000000..6237d13e --- /dev/null +++ b/src/utils/ajv.ts @@ -0,0 +1,3 @@ +import Ajv from 'ajv'; + +export const ajv = new Ajv({ coerceTypes: 'array', strict: false }); diff --git a/src/utils/generic.ts b/src/utils/generic.ts new file mode 100644 index 00000000..1e1f5689 --- /dev/null +++ b/src/utils/generic.ts @@ -0,0 +1,27 @@ +/** + * Recursively gets the value in a nested object for each key in the keys array. + * Each key can be a dot-separated path (e.g. 'a.b.c'). + * Returns an object mapping each key to its resolved value (or undefined if not found). + */ +export function getValuesByDotKeys(obj: T, keys: string[]): Record { + const result: Record = {}; + for (const key of keys) { + const path = key.split('.'); + let current: unknown = obj; + for (const segment of path) { + if ( + current !== null + && typeof current === 'object' + && Object.prototype.hasOwnProperty.call(current, segment) + ) { + // Use index signature to avoid 'any' and type errors + current = (current as Record)[segment]; + } else { + current = undefined; + break; + } + } + result[key] = current; + } + return result; +} diff --git a/src/utils/pricing-info.ts b/src/utils/pricing-info.ts new file mode 100644 index 00000000..cc9a00e9 --- /dev/null +++ b/src/utils/pricing-info.ts @@ -0,0 +1,96 @@ +import type { ActorRunPricingInfo } from 'apify-client'; + +import { ACTOR_PRICING_MODEL } from '../const.js'; +import type { ExtendedPricingInfo } from '../types.js'; + +/** + * Returns the most recent valid pricing information from a list of pricing infos, + * based on the provided current date. + * + * Filters out pricing infos that have a `startedAt` date in the future or missing, + * then sorts the remaining infos by `startedAt` in descending order (most recent first). + * Returns the most recent valid pricing info, or `null` if none are valid. + */ +export function getCurrentPricingInfo(pricingInfos: ActorRunPricingInfo[], now: Date): ActorRunPricingInfo | null { + // Filter out all future dates and those without a startedAt date + const validPricingInfos = pricingInfos.filter((info) => { + if (!info.startedAt) return false; + const startedAt = new Date(info.startedAt); + return startedAt <= now; + }); + + // Sort and return the most recent pricing info + validPricingInfos.sort((a, b) => { + const aDate = new Date(a.startedAt || 0); + const bDate = new Date(b.startedAt || 0); + return bDate.getTime() - aDate.getTime(); // Sort descending + }); + if (validPricingInfos.length > 0) { + return validPricingInfos[0]; // Return the most recent pricing info + } + + return null; +} + +function convertMinutesToGreatestUnit(minutes: number): { value: number; unit: string } { + if (minutes < 60) { + return { value: minutes, unit: 'minutes' }; + } if (minutes < 60 * 24) { // Less than 24 hours + return { value: Math.floor(minutes / 60), unit: 'hours' }; + } // 24 hours or more + return { value: Math.floor(minutes / (60 * 24)), unit: 'days' }; +} + +function payPerEventPricingToString(pricingPerEvent: ExtendedPricingInfo['pricingPerEvent']): string { + if (!pricingPerEvent || !pricingPerEvent.actorChargeEvents) return 'No event pricing information available.'; + const eventStrings: string[] = []; + for (const event of Object.values(pricingPerEvent.actorChargeEvents)) { + let eventStr = `- ${event.eventTitle}: ${event.eventDescription} `; + if (typeof event.eventPriceUsd === 'number') { + eventStr += `(Flat price: $${event.eventPriceUsd} per event)`; + } else if (event.eventTieredPricingUsd) { + const tiers = Object.entries(event.eventTieredPricingUsd) + .map(([tier, price]) => `${tier}: $${price.tieredEventPriceUsd}`) + .join(', '); + eventStr += `(Tiered pricing: ${tiers} per event)`; + } else { + eventStr += '(No price info)'; + } + eventStrings.push(eventStr); + } + return `This Actor charges per event as follows:\n${eventStrings.join('\n')}`; +} + +export function pricingInfoToString(pricingInfo: ExtendedPricingInfo | null): string { + // If there is no pricing infos entries the Actor is free to use + // based on https://github.com/apify/apify-core/blob/058044945f242387dde2422b8f1bef395110a1bf/src/packages/actor/src/paid_actors/paid_actors_common.ts#L691 + if (pricingInfo === null || pricingInfo.pricingModel === ACTOR_PRICING_MODEL.FREE) { + return 'This Actor is free to use; the user only pays for the computing resources consumed by the Actor.'; + } + if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PRICE_PER_DATASET_ITEM) { + const customUnitName = pricingInfo.unitName !== 'result' ? pricingInfo.unitName : ''; + // Handle tiered pricing if present + if (pricingInfo.tieredPricing && Object.keys(pricingInfo.tieredPricing).length > 0) { + const tiers = Object.entries(pricingInfo.tieredPricing) + .map(([tier, obj]) => `${tier}: $${obj.tieredPricePerUnitUsd * 1000} per 1000 ${customUnitName || 'results'}`) + .join(', '); + return `This Actor charges per results${customUnitName ? ` (in this case named ${customUnitName})` : ''}; tiered pricing per 1000 ${customUnitName || 'results'}: ${tiers}.`; + } + return `This Actor charges per results${customUnitName ? ` (in this case named ${customUnitName})` : ''}; the price per 1000 ${customUnitName || 'results'} is ${pricingInfo.pricePerUnitUsd as number * 1000} USD.`; + } + if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.FLAT_PRICE_PER_MONTH) { + const { value, unit } = convertMinutesToGreatestUnit(pricingInfo.trialMinutes || 0); + // Handle tiered pricing if present + if (pricingInfo.tieredPricing && Object.keys(pricingInfo.tieredPricing).length > 0) { + const tiers = Object.entries(pricingInfo.tieredPricing) + .map(([tier, obj]) => `${tier}: $${obj.tieredPricePerUnitUsd} per month`) + .join(', '); + return `This Actor is rental and thus has tiered pricing per month: ${tiers}, with a trial period of ${value} ${unit}.`; + } + return `This Actor is rental and thus has a flat price of ${pricingInfo.pricePerUnitUsd} USD per month, with a trial period of ${value} ${unit}.`; + } + if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PAY_PER_EVENT) { + return payPerEventPricingToString(pricingInfo.pricingPerEvent); + } + return 'unknown'; +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 8ab1addb..57b2a529 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -116,13 +116,13 @@ export async function createMcpStdioClient( /** * Adds an Actor as a tool using the ADD_ACTOR helper tool. * @param client - MCP client instance - * @param actorName - Name of the Actor to add + * @param actor - Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser". */ -export async function addActor(client: Client, actorName: string): Promise { +export async function addActor(client: Client, actor: string): Promise { await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { - actorName, + actor, }, }); } diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 8de5c97d..905bf284 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { defaults, HelperTools } from '../../src/const.js'; import { addRemoveTools, defaultTools } from '../../src/tools/index.js'; +import type { ISearchActorsResult } from '../../src/tools/store_collection.js'; import { actorNameToToolName } from '../../src/tools/utils.js'; import { ACTOR_MCP_SERVER_ACTOR_NAME, ACTOR_PYTHON_EXAMPLE, DEFAULT_ACTOR_NAMES, DEFAULT_TOOL_NAMES } from '../const.js'; import { addActor, type McpClientOptions } from '../helpers.js'; @@ -39,14 +40,18 @@ async function callPythonExampleActor(client: Client, selectedToolName: string) type ContentItem = { text: string; type: string }; const content = result.content as ContentItem[]; // The result is { content: [ ... ] }, and the last content is the sum - expect(content[content.length - 1]).toEqual({ + const expected = { text: JSON.stringify({ first_number: 1, second_number: 2, sum: 3, }), type: 'text', - }); + }; + // Parse the JSON to compare objects regardless of property order + const actual = content[content.length - 1]; + expect(JSON.parse(actual.text)).toEqual(JSON.parse(expected.text)); + expect(actual.type).toBe(expected.type); } export function createIntegrationTestsSuite( @@ -131,7 +136,7 @@ export function createIntegrationTestsSuite( // Check that the Actor is not in the tools list expect(names).not.toContain(selectedToolName); // Add Actor dynamically - await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { actorName: ACTOR_PYTHON_EXAMPLE } }); + await addActor(client, ACTOR_PYTHON_EXAMPLE); // Check if tools was added const namesAfterAdd = getToolNames(await client.listTools()); @@ -142,7 +147,8 @@ export function createIntegrationTestsSuite( await client.close(); }); - it('should remove Actor from tools list', async () => { + // TODO: disabled for now, remove tools is disabled and might be removed in the future + it.skip('should remove Actor from tools list', async () => { const actor = ACTOR_PYTHON_EXAMPLE; const selectedToolName = actorNameToToolName(actor); const client = await createClientFn({ @@ -197,12 +203,16 @@ export function createIntegrationTestsSuite( }, }); const content = result.content as {text: string}[]; - const actors = content.map((item) => JSON.parse(item.text)); + expect(content.length).toBe(1); + const resultJson = JSON.parse(content[0].text) as ISearchActorsResult; + const { actors } = resultJson; + expect(actors.length).toBe(resultJson.total); expect(actors.length).toBeGreaterThan(0); // Check that no rental Actors are present for (const actor of actors) { - expect(actor.currentPricingInfo.pricingModel).not.toBe('FLAT_PRICE_PER_MONTH'); + // Since we now return the pricingInfo as a string, we need to check if it contains the string + expect(actor.pricingInfo).not.toContain('This Actor is rental'); } await client.close(); @@ -220,7 +230,7 @@ export function createIntegrationTestsSuite( } }); // Add Actor dynamically - await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { actorName: ACTOR_PYTHON_EXAMPLE } }); + await client.callTool({ name: HelperTools.ACTOR_ADD, arguments: { actor: ACTOR_PYTHON_EXAMPLE } }); expect(hasReceivedNotification).toBe(true); diff --git a/tests/unit/utils.actor.test.ts b/tests/unit/utils.actor.test.ts new file mode 100644 index 00000000..390eadc3 --- /dev/null +++ b/tests/unit/utils.actor.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { getActorDefinitionStorageFieldNames } from '../../src/utils/actor.js'; + +describe('getActorDefinitionStorageFieldNames', () => { + it('should return an array of field names from a single view (display.properties and transformation.fields)', () => { + const storage = { + views: { + view1: { + display: { + properties: { + foo: {}, + bar: {}, + baz: {}, + }, + }, + transformation: { + fields: ['baz', 'qux', 'extra'], + }, + }, + }, + }; + const result = getActorDefinitionStorageFieldNames(storage); + expect(result.sort()).toEqual(['bar', 'baz', 'extra', 'foo', 'qux']); + }); + + it('should return unique field names from multiple views (display.properties and transformation.fields)', () => { + const storage = { + views: { + view1: { + display: { + properties: { + foo: {}, + bar: {}, + }, + }, + transformation: { + fields: ['foo', 'alpha'], + }, + }, + view2: { + display: { + properties: { + bar: {}, + baz: {}, + }, + }, + transformation: { + fields: ['baz', 'beta', 'alpha'], + }, + }, + }, + }; + const result = getActorDefinitionStorageFieldNames(storage); + expect(result.sort()).toEqual(['alpha', 'bar', 'baz', 'beta', 'foo']); + }); + + it('should return an empty array if no properties or fields are present', () => { + const storage = { + views: { + view1: { + display: { + properties: {}, + }, + transformation: { + fields: [], + }, + }, + }, + }; + const result = getActorDefinitionStorageFieldNames(storage); + expect(result).toEqual([]); + }); + + it('should handle empty views object', () => { + const storage = { views: {} }; + const result = getActorDefinitionStorageFieldNames(storage); + expect(result).toEqual([]); + }); + + it('should handle missing transformation or display', () => { + const storage = { + views: { + view1: { + display: { + properties: { foo: {} }, + }, + }, + view2: { + transformation: { + fields: ['bar', 'baz'], + }, + }, + view3: {}, + }, + }; + const result = getActorDefinitionStorageFieldNames(storage); + expect(result.sort()).toEqual(['bar', 'baz', 'foo']); + }); +}); diff --git a/tests/unit/utils.generic.test.ts b/tests/unit/utils.generic.test.ts new file mode 100644 index 00000000..4924c632 --- /dev/null +++ b/tests/unit/utils.generic.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { getValuesByDotKeys } from '../../src/utils/generic.js'; + +describe('getValuesByDotKeys', () => { + it('should get value for a key without dot', () => { + const obj = { key: 'value', other: 123 }; + const result = getValuesByDotKeys(obj, ['key']); + expect(result).toEqual({ key: 'value' }); + }); + it('should get values for simple keys', () => { + const obj = { a: 1, b: 2 }; + const result = getValuesByDotKeys(obj, ['a', 'b', 'c']); + expect(result).toEqual({ a: 1, b: 2, c: undefined }); + }); + + it('should get values for nested dot keys', () => { + const obj = { a: { b: { c: 42 } }, x: { y: 7 } }; + const result = getValuesByDotKeys(obj, ['a.b.c', 'x.y', 'a.b', 'x.z']); + expect(result).toEqual({ 'a.b.c': 42, 'x.y': 7, 'a.b': { c: 42 }, 'x.z': undefined }); + }); + + it('should return undefined for missing paths', () => { + const obj = { foo: { bar: 1 } }; + const result = getValuesByDotKeys(obj, ['foo.baz', 'baz', 'foo.bar.baz']); + expect(result).toEqual({ 'foo.baz': undefined, baz: undefined, 'foo.bar.baz': undefined }); + }); + + it('should handle non-object values in the path', () => { + const obj = { a: { b: 5 }, x: 10 }; + const result = getValuesByDotKeys(obj, ['a.b', 'x.y', 'x']); + expect(result).toEqual({ 'a.b': 5, 'x.y': undefined, x: 10 }); + }); + + it('should work with empty keys array', () => { + const obj = { a: 1 }; + const result = getValuesByDotKeys(obj, []); + expect(result).toEqual({}); + }); + + it('should work with empty object', () => { + const obj = {}; + const result = getValuesByDotKeys(obj, ['a', 'b.c']); + expect(result).toEqual({ a: undefined, 'b.c': undefined }); + }); +});