Skip to content

Commit d7bdb9e

Browse files
MQ37jirispilka
andauthored
fix: disable search for rental Actors (#142)
* disable search for rental Actors * fix: update return type of searchActorsByKeywords to ensure consistent promise resolution * simplify implementation, todo get rented Actors from mongo * add support for filtering if user rented Actor Ids list if passed from apify-mcp-server * add naive integration test * merge master * Update src/types.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/store_collection.ts Co-authored-by: Jiří Spilka <[email protected]> * fix lint --------- Co-authored-by: Jiri Spilka <[email protected]> Co-authored-by: Jiří Spilka <[email protected]>
1 parent 8733c0d commit d7bdb9e

File tree

5 files changed

+82
-6
lines changed

5 files changed

+82
-6
lines changed

src/const.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,19 @@ export const ACTOR_ADDITIONAL_INSTRUCTIONS = `Never call/execute tool/Actor unle
5656

5757
export const TOOL_CACHE_MAX_SIZE = 500;
5858
export const TOOL_CACHE_TTL_SECS = 30 * 60;
59+
60+
export const ACTOR_PRICING_MODEL = {
61+
/** Rental actors */
62+
FLAT_PRICE_PER_MONTH: 'FLAT_PRICE_PER_MONTH',
63+
FREE: 'FREE',
64+
/** Pay per result (PPR) actors */
65+
PRICE_PER_DATASET_ITEM: 'PRICE_PER_DATASET_ITEM',
66+
/** Pay per event (PPE) actors */
67+
PAY_PER_EVENT: 'PAY_PER_EVENT',
68+
} as const;
69+
70+
/**
71+
* Used in search Actors tool to search above the input supplied limit,
72+
* so we can safely filter out rental Actors from the search and ensure we return some results.
73+
*/
74+
export const ACTOR_SEARCH_ABOVE_LIMIT = 50;

src/mcp/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,12 @@ export class ActorsMcpServer {
351351
// eslint-disable-next-line prefer-const
352352
let { name, arguments: args } = request.params;
353353
const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN) as string;
354+
const userRentedActorIds = request.params.userRentedActorIds as string[] | undefined;
354355

355356
// Remove apifyToken from request.params just in case
356357
delete request.params.apifyToken;
358+
// Remove other custom params passed from apify-mcp-server
359+
delete request.params.userRentedActorIds;
357360

358361
// Validate token
359362
if (!apifyToken) {
@@ -419,6 +422,7 @@ export class ActorsMcpServer {
419422
apifyMcpServer: this,
420423
mcpServer: this.server,
421424
apifyToken,
425+
userRentedActorIds,
422426
}) as object;
423427

424428
return { ...res };

src/tools/store_collection.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { z } from 'zod';
44
import zodToJsonSchema from 'zod-to-json-schema';
55

66
import { ApifyClient } from '../apify-client.js';
7-
import { HelperTools } from '../const.js';
8-
import type { ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js';
7+
import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js';
8+
import type { ActorPricingModel, ActorStorePruned, HelperTool, PricingInfo, ToolEntry } from '../types.js';
99

1010
function pruneActorStoreInfo(response: ActorStoreList): ActorStorePruned {
1111
const stats = response.stats || {};
@@ -38,7 +38,7 @@ export async function searchActorsByKeywords(
3838
apifyToken: string,
3939
limit: number | undefined = undefined,
4040
offset: number | undefined = undefined,
41-
): Promise<ActorStorePruned[] | null> {
41+
): Promise<ActorStorePruned[]> {
4242
const client = new ApifyClient({ token: apifyToken });
4343
const results = await client.store().list({ search, limit, offset });
4444
return results.items.map((x) => pruneActorStoreInfo(x));
@@ -68,6 +68,29 @@ export const searchActorsArgsSchema = z.object({
6868
.describe('Filters the results by the specified category.'),
6969
});
7070

71+
/**
72+
* Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors),
73+
* unless the actor's ID is present in the user's rented actor IDs list.
74+
*
75+
* This is necessary because the Store list API does not support filtering by multiple pricing models at once.
76+
*
77+
* @param actors - Array of ActorStorePruned objects to filter.
78+
* @param userRentedActorIds - Array of Actor IDs that the user has rented.
79+
* @returns Array of Actors excluding those with 'FLAT_PRICE_PER_MONTH' pricing model (= rental Actors),
80+
* except for Actors that the user has rented (whose IDs are in userRentedActorIds).
81+
*/
82+
function filterRentalActors(
83+
actors: ActorStorePruned[],
84+
userRentedActorIds: string[],
85+
): ActorStorePruned[] {
86+
// Store list API does not support filtering by two pricing models at once,
87+
// so we filter the results manually after fetching them.
88+
return actors.filter((actor) => (
89+
actor.currentPricingInfo.pricingModel as ActorPricingModel) !== 'FLAT_PRICE_PER_MONTH'
90+
|| userRentedActorIds.includes(actor.id),
91+
);
92+
}
93+
7194
/**
7295
* https://docs.apify.com/api/v2/store-get
7396
*/
@@ -86,14 +109,16 @@ export const searchActors: ToolEntry = {
86109
inputSchema: zodToJsonSchema(searchActorsArgsSchema),
87110
ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)),
88111
call: async (toolArgs) => {
89-
const { args, apifyToken } = toolArgs;
112+
const { args, apifyToken, userRentedActorIds } = toolArgs;
90113
const parsed = searchActorsArgsSchema.parse(args);
91-
const actors = await searchActorsByKeywords(
114+
let actors = await searchActorsByKeywords(
92115
parsed.search,
93116
apifyToken,
94-
parsed.limit,
117+
parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT,
95118
parsed.offset,
96119
);
120+
actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit);
121+
97122
return { content: actors?.map((item) => ({ type: 'text', text: JSON.stringify(item) })) };
98123
},
99124
} as HelperTool,

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Notification, Request } from '@modelcontextprotocol/sdk/types.js';
44
import type { ValidateFunction } from 'ajv';
55
import type { ActorDefaultRunOptions, ActorDefinition } from 'apify-client';
66

7+
import type { ACTOR_PRICING_MODEL } from './const.js';
78
import type { ActorsMcpServer } from './mcp/server.js';
89

910
export interface ISchemaProperties {
@@ -95,6 +96,8 @@ export type InternalToolArgs = {
9596
mcpServer: Server;
9697
/** Apify API token */
9798
apifyToken: string;
99+
/** List of Actor IDs that the user has rented */
100+
userRentedActorIds?: string[];
98101
}
99102

100103
/**
@@ -199,3 +202,6 @@ export interface ToolCacheEntry {
199202
expiresAt: number;
200203
tool: ToolEntry;
201204
}
205+
206+
// Utility type to get a union of values from an object type
207+
export type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL];

tests/integration/suite.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,31 @@ export function createIntegrationTestsSuite(
176176
await client.close();
177177
});
178178

179+
// It should filter out all rental Actors only if we run locally or as standby, where
180+
// we cannot access MongoDB to get the user's rented Actors.
181+
// In case of apify-mcp-server it should include user's rented Actors.
182+
it('should filter out all rental Actors from store search', async () => {
183+
const client = await createClientFn();
184+
185+
const result = await client.callTool({
186+
name: HelperTools.STORE_SEARCH,
187+
arguments: {
188+
search: 'rental',
189+
limit: 100,
190+
},
191+
});
192+
const content = result.content as {text: string}[];
193+
const actors = content.map((item) => JSON.parse(item.text));
194+
expect(actors.length).toBeGreaterThan(0);
195+
196+
// Check that no rental Actors are present
197+
for (const actor of actors) {
198+
expect(actor.currentPricingInfo.pricingModel).not.toBe('FLAT_PRICE_PER_MONTH');
199+
}
200+
201+
await client.close();
202+
});
203+
179204
// Execute only when we can get the MCP server instance - currently skips only stdio
180205
// is skipped because we are running a compiled version through node and there is no way (easy)
181206
// to get the MCP server instance

0 commit comments

Comments
 (0)