diff --git a/src/const.ts b/src/const.ts index 7a82b8b8..b27f5d3c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -56,3 +56,19 @@ export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unle export const TOOL_CACHE_MAX_SIZE = 500; export const TOOL_CACHE_TTL_SECS = 30 * 60; + +export const ACTOR_PRICING_MODEL = { + /** Rental actors */ + FLAT_PRICE_PER_MONTH: 'FLAT_PRICE_PER_MONTH', + FREE: 'FREE', + /** Pay per result (PPR) actors */ + PRICE_PER_DATASET_ITEM: 'PRICE_PER_DATASET_ITEM', + /** Pay per event (PPE) actors */ + PAY_PER_EVENT: 'PAY_PER_EVENT', +} as const; + +/** + * Used in search Actors tool to search above the input supplied limit, + * so we can safely filter out rental Actors from the search and ensure we return some results. + */ +export const ACTOR_SEARCH_ABOVE_LIMIT = 50; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d5b10cee..5a1fbf33 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -351,9 +351,12 @@ export class ActorsMcpServer { // eslint-disable-next-line prefer-const let { name, arguments: args } = request.params; const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN) as string; + const userRentedActorIds = request.params.userRentedActorIds as string[] | undefined; // Remove apifyToken from request.params just in case delete request.params.apifyToken; + // Remove other custom params passed from apify-mcp-server + delete request.params.userRentedActorIds; // Validate token if (!apifyToken) { @@ -419,6 +422,7 @@ export class ActorsMcpServer { apifyMcpServer: this, mcpServer: this.server, apifyToken, + userRentedActorIds, }) as object; return { ...res }; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index b6debf1f..789ccbf1 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; -import { HelperTools } from '../const.js'; -import type { ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.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 || {}; @@ -38,7 +38,7 @@ export async function searchActorsByKeywords( 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)); @@ -68,6 +68,29 @@ export const searchActorsArgsSchema = z.object({ .describe('Filters the results by the specified category.'), }); +/** + * 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. + * + * This is necessary because the Store list API does not support filtering by multiple pricing models at once. + * + * @param actors - Array of ActorStorePruned objects to filter. + * @param userRentedActorIds - Array of Actor IDs that the user has rented. + * @returns Array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model (= rental Actors), + * except for Actors that the user has rented (whose IDs are in userRentedActorIds). + */ +function filterRentalActors( + actors: ActorStorePruned[], + userRentedActorIds: string[], +): ActorStorePruned[] { + // 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) => ( + actor.currentPricingInfo.pricingModel as ActorPricingModel) !== 'FLAT_PRICE_PER_MONTH' + || userRentedActorIds.includes(actor.id), + ); +} + /** * https://docs.apify.com/api/v2/store-get */ @@ -86,14 +109,16 @@ export const searchActors: ToolEntry = { inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, apifyToken, userRentedActorIds } = toolArgs; const parsed = searchActorsArgsSchema.parse(args); - const actors = await searchActorsByKeywords( + let actors = await searchActorsByKeywords( parsed.search, apifyToken, - parsed.limit, + parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, parsed.offset, ); + actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); + return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) }; }, } as HelperTool, diff --git a/src/types.ts b/src/types.ts index e089d3f5..cb4fb39e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import type { Notification, Request } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client'; +import type { ACTOR_PRICING_MODEL } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; export interface ISchemaProperties { @@ -95,6 +96,8 @@ export type InternalToolArgs = { mcpServer: Server; /** Apify API token */ apifyToken: string; + /** List of Actor IDs that the user has rented */ + userRentedActorIds?: string[]; } /** @@ -199,3 +202,6 @@ export interface ToolCacheEntry { expiresAt: number; tool: ToolEntry; } + +// Utility type to get a union of values from an object type +export type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL]; diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 1a583bb8..bd1a7e8e 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -176,6 +176,31 @@ export function createIntegrationTestsSuite( await client.close(); }); + // It should filter out all rental Actors only if we run locally or as standby, where + // we cannot access MongoDB to get the user's rented Actors. + // In case of apify-mcp-server it should include user's rented Actors. + it('should filter out all rental Actors from store search', async () => { + const client = await createClientFn(); + + const result = await client.callTool({ + name: HelperTools.STORE_SEARCH, + arguments: { + search: 'rental', + limit: 100, + }, + }); + const content = result.content as {text: string}[]; + const actors = content.map((item) => JSON.parse(item.text)); + 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'); + } + + await client.close(); + }); + // Execute only when we can get the MCP server instance - currently skips only stdio // is skipped because we are running a compiled version through node and there is no way (easy) // to get the MCP server instance