Skip to content
16 changes: 16 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 4 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -419,6 +422,7 @@ export class ActorsMcpServer {
apifyMcpServer: this,
mcpServer: this.server,
apifyToken,
userRentedActorIds,
}) as object;

return { ...res };
Expand Down
37 changes: 31 additions & 6 deletions src/tools/store_collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down Expand Up @@ -38,7 +38,7 @@ export async function searchActorsByKeywords(
apifyToken: string,
limit: number | undefined = undefined,
offset: number | undefined = undefined,
): Promise<ActorStorePruned[] | null> {
): Promise<ActorStorePruned[]> {
const client = new ApifyClient({ token: apifyToken });
const results = await client.store().list({ search, limit, offset });
return results.items.map((x) => pruneActorStoreInfo(x));
Expand Down Expand Up @@ -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,
* 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
*/
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -199,3 +202,6 @@ export interface ToolCacheEntry {
expiresAt: number;
tool: ToolEntry;
}

// Utility type to get the union of values of an object type
export type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL];
25 changes: 25 additions & 0 deletions tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down