Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4e23ca
feat: Surface compact input schema on search-actors results
MQ37 May 5, 2026
f44962c
fix: Drop limit cap, drop rental over-fetch, add observability log
MQ37 May 5, 2026
a1b1bb0
chore: Render full inputSchema in text card, rename "Input fields" to…
MQ37 May 5, 2026
028d158
chore: Trim PR comments and redundant tests
MQ37 May 5, 2026
c04d43a
fix: Clamp `limit` to STORE_INPUT_SCHEMA_PAGE_LIMIT when includeInput…
MQ37 May 5, 2026
a12ffdd
feat: Trim text inputSchema to 20 fields with `... (+N more)` suffix
MQ37 May 5, 2026
6106ffb
feat: Cap search-actors limit at 10 and drop rental observability check
MQ37 May 7, 2026
a7c9d05
revert: Drop "constants in src/const.ts" CLAUDE.md rule
MQ37 May 7, 2026
cfe7260
refactor: Rename STORE_INPUT_SCHEMA_* constants
MQ37 May 7, 2026
5c79c36
fix(actor_search): Throw on incompatible limit + includeInputSchema c…
MQ37 May 7, 2026
3120d46
refactor: Rename LLM-facing "input schema" to "input fields"
MQ37 May 7, 2026
2c22b9f
test(actor_search): Replace double-cast factory with typed defaults +…
MQ37 May 7, 2026
8824929
test(actor_card): Fix misleading truncation test + add real coverage
MQ37 May 7, 2026
48706f5
refactor: Rename structured `inputSchema` -> `inputFields` and trim r…
MQ37 May 7, 2026
a2be576
test(integration): Update rental Actors test for the new `limit` cap
MQ37 May 7, 2026
df9148a
test(actor_card): Drop redundant boundary test, fold negative asserti…
MQ37 May 7, 2026
777419a
refactor(search): rename searchAndFilterActors -> searchAgentSafeActors
MQ37 May 7, 2026
b6833fe
docs(search-actors): drop stale rental-filter caveat from tool descri…
MQ37 May 7, 2026
ea5182b
refactor(schemas): encode inputFields shape on actorInfoSchema
MQ37 May 7, 2026
b9ac04f
Update src/tools/core/search_actors_common.ts
MQ37 May 11, 2026
4bf8cad
Update src/const.ts
MQ37 May 11, 2026
3f80672
refactor(actor-search): rename text-card field cap and require limit
MQ37 May 11, 2026
3ca6cba
refactor(actor-search): drop client-side limit cap throw, let API enf…
MQ37 May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ export const MCP_SERVER_CACHE_MAX_SIZE = 500;
export const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes
export const USER_CACHE_MAX_SIZE = 200;
export const USER_CACHE_TTL_SECS = 60 * 60; // 1 hour
/** API rejects `includeInputSchema=true` above this; mirrors apify-core `MAX_LIMIT_WITH_INPUT_SCHEMA`. */
export const MAX_LIMIT_WITH_INPUT_SCHEMA = 10;
/** Max input fields shown inline in the text Actor card; structured output keeps the full schema. */
export const MAX_INPUT_FIELDS_IN_TEXT_CARD = 20;

export const ACTOR_PRICING_MODEL = {
/** Rental Actors */
Expand Down
7 changes: 3 additions & 4 deletions src/tools/apps/search_actors_widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { HelperTools } from '../../const.js';
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
import { formatActorForWidget } from '../../utils/actor_card.js';
import { searchAndFilterActors } from '../../utils/actor_search.js';
import { searchAgentSafeActors } from '../../utils/actor_search.js';
import { compileSchema } from '../../utils/ajv.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
Expand Down Expand Up @@ -56,18 +56,17 @@ export const searchActorsWidgetTool: ToolEntry = Object.freeze({
openWorldHint: false,
},
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
const { args, apifyToken, apifyClient, apifyMcpServer } = toolArgs;
const parsed = searchActorsWidgetArgsSchema.parse(args);
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
const [actors, { userPlanTier }] = await Promise.all([
searchAndFilterActors({
searchAgentSafeActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
}),
getUserInfoCached(apifyToken, apifyClient),
]);
Expand Down
14 changes: 8 additions & 6 deletions src/tools/core/search_actors_common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import dedent from 'dedent';
import { z } from 'zod';

import { HelperTools } from '../../const.js';
import { HelperTools, MAX_LIMIT_WITH_INPUT_SCHEMA } from '../../const.js';
import type { ActorStoreList, HelperTool, StructuredActorCard, ToolInputSchema } from '../../types.js';
import { DEFAULT_CARD_OPTIONS, formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
import { compileSchema } from '../../utils/ajv.js';
Expand All @@ -20,9 +20,9 @@ export const searchActorsBaseArgsSchema = z.object({
limit: z.number()
.int()
.min(1)
.max(100)
.max(MAX_LIMIT_WITH_INPUT_SCHEMA)
.default(5)
.describe('The maximum number of Actors to return (default = 5)'),
.describe(`The maximum number of Actors to return (max = ${MAX_LIMIT_WITH_INPUT_SCHEMA}, default = 5).`),
offset: z.number()
.int()
.min(0)
Expand Down Expand Up @@ -77,9 +77,10 @@ Usage:
- Prefer broad, generic keywords - use just the platform name (e.g. "Instagram" instead of "Instagram scraper").
- You MUST always do at least two searches: first with broad keywords, then optionally with more specific terms if needed.

Important limitations: This tool does not return full Actor documentation, input schemas, or detailed usage instructions - only summary information.
For complete Actor details, use the ${HelperTools.ACTOR_GET_DETAILS} tool.
The search is limited to publicly available Actors and may not include private, rental, or restricted Actors depending on the user's access level.
Important limitations: This tool does not return full Actor documentation or detailed usage instructions - only summary information.
Each result lists the Actor's input fields with their types (e.g. \`url: string, maxResults?: number\`) so you can construct an Actor call directly without a separate ${HelperTools.ACTOR_GET_DETAILS} round-trip.
For complete Actor details (per-field descriptions, defaults, README), use the ${HelperTools.ACTOR_GET_DETAILS} tool.
The search is limited to publicly available Actors and excludes rental and restricted Actors.

Returns list of Actor cards with the following info:
**Title:** Markdown header linked to Store page
Expand All @@ -91,6 +92,7 @@ Returns list of Actor cards with the following info:
- **Pricing:** Details with pricing link
- **Stats:** Usage, success rate, bookmarks
- **Rating:** Out of 5 (if available)
- **Input fields:** Inline list of input field names and types (e.g. \`url: string, maxResults?: number\`); \`?\` marks optional fields
`;

/**
Expand Down
7 changes: 3 additions & 4 deletions src/tools/default/search_actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import dedent from 'dedent';

import { HelperTools } from '../../const.js';
import type { InternalToolArgs, ToolEntry } from '../../types.js';
import { searchAndFilterActors } from '../../utils/actor_search.js';
import { searchAgentSafeActors } from '../../utils/actor_search.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import {
Expand All @@ -19,18 +19,17 @@ import {
export const defaultSearchActors: ToolEntry = Object.freeze({
...searchActorsMetadata,
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
const { args, apifyToken, apifyClient, apifyMcpServer } = toolArgs;
const parsed = searchActorsArgsSchema.parse(args);
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
const [actors, { userPlanTier }] = await Promise.all([
searchAndFilterActors({
searchAgentSafeActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
}),
getUserInfoCached(apifyToken, apifyClient),
]);
Expand Down
26 changes: 26 additions & 0 deletions src/tools/structured_output_schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ export const actorInfoSchema = {
},
modifiedAt: { type: 'string', description: 'Last modification date' },
isDeprecated: { type: 'boolean', description: 'Whether the Actor is deprecated' },
// Mirrors `ActorStoreInputSchema` in src/types.ts; only `type` is preserved per
// field by apify-core's `trimInputSchema`, so the per-field shape stays minimal.
inputFields: {
type: 'object' as const, // Literal type required for MCP SDK type compatibility
description: 'Compact JSON-Schema-shaped descriptor of the Actor input; only `type` is preserved per field.',
properties: {
type: { type: 'string', description: 'Always `"object"`.' },
properties: {
type: 'object' as const, // Literal type required for MCP SDK type compatibility
description: 'Map of input field name to its type descriptor.',
additionalProperties: {
type: 'object' as const, // Literal type required for MCP SDK type compatibility
properties: {
type: { description: 'JSON Schema field type — string or array of strings.' },
},
required: ['type'],
},
},
required: {
type: 'array' as const, // Literal type required for MCP SDK type compatibility
items: { type: 'string' },
description: 'Names of required input fields.',
},
},
required: ['type', 'properties'],
},
},
required: ['url', 'id', 'fullName', 'developer', 'description', 'categories', 'isDeprecated'],
};
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ export type ActorStoreList = ActorStoreListOutdated & {
isWhiteListedForAgenticPayments?: boolean;
notice?: string | null;
userFullName?: string;
/** Populated when the search call is made with `includeInputSchema=true`. */
inputSchema?: ActorStoreInputSchema;
stats: ActorStats & {
actorReviewCount?: number;
actorReviewRating?: number;
Expand Down Expand Up @@ -564,6 +566,13 @@ export type ActorsMcpServerOptions = {
uiMode?: string;
}

/** Compact schema returned by `GET /v2/store?includeInputSchema=true`; produced by apify-core `trimInputSchema`. */
export type ActorStoreInputSchema = {
type: 'object';
properties: Record<string, { type: string | string[] }>;
required?: string[];
};

export type StructuredActorCard = {
title?: string;
url: string;
Expand All @@ -590,6 +599,7 @@ export type StructuredActorCard = {
};
modifiedAt?: string;
isDeprecated: boolean;
inputFields?: ActorStoreInputSchema;
}

/**
Expand Down
29 changes: 27 additions & 2 deletions src/utils/actor_card.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { APIFY_STORE_URL } from '../const.js';
import type { Actor, ActorCardOptions, ActorStoreList, StructuredActorCard } from '../types.js';
import { APIFY_STORE_URL, MAX_INPUT_FIELDS_IN_TEXT_CARD } from '../const.js';
import type { Actor, ActorCardOptions, ActorStoreInputSchema, ActorStoreList, StructuredActorCard } from '../types.js';
import {
getCurrentPricingInfo,
type PricingInfo,
Expand All @@ -11,6 +11,25 @@ import {
type StructuredPricingInfo,
} from './pricing_info.js';

function getInputSchema(actor: Actor | ActorStoreList): ActorStoreInputSchema | undefined {
return 'inputSchema' in actor ? actor.inputSchema : undefined;
}

function inputFieldsToString(inputSchema: ActorStoreInputSchema): string | null {
const entries = Object.entries(inputSchema.properties);
if (entries.length === 0) return null;

const requiredSet = new Set(inputSchema.required ?? []);
const shown = entries.slice(0, MAX_INPUT_FIELDS_IN_TEXT_CARD);
const fields = shown
Comment thread
MQ37 marked this conversation as resolved.
.map(([name, prop]) => `${name}${requiredSet.has(name) ? '' : '?'}: ${Array.isArray(prop.type) ? prop.type.join('|') : prop.type}`)
.join(', ');
const overflow = entries.length - shown.length;
const suffix = overflow > 0 ? ` ... (+${overflow} more)` : '';

return `- **Input fields:** ${fields}${suffix}`;
}

// Helper function to format categories from uppercase with underscores to a proper case
function formatCategories(categories?: string[]): string[] {
if (!categories) return [];
Expand Down Expand Up @@ -217,6 +236,11 @@ export function formatActorToActorCard(
markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.');
}
}
const inputSchema = getInputSchema(actor);
if (inputSchema) {
const line = inputFieldsToString(inputSchema);
if (line) markdownLines.push(line);
}
return markdownLines.join('\n');
}

Expand Down Expand Up @@ -249,6 +273,7 @@ export function formatActorToStructuredCard(
rating: data.rating,
modifiedAt: data.modifiedAt,
isDeprecated: data.isDeprecated,
inputFields: getInputSchema(actor),
};
}

Expand Down
6 changes: 5 additions & 1 deletion src/utils/actor_details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ export async function fetchActorDetails(
const [actorInfo, buildInfo, storeActors]: [Actor | undefined, Build | undefined, ActorStoreList[]] = await Promise.all([
actor.get(),
actor.defaultBuild().then(async (build) => build.get()),
searchActorsByKeywords(actorSlug, apifyClient.token || '', ACTOR_DETAILS_PICTURE_SEARCH_LIMIT).catch(() => []),
searchActorsByKeywords({
search: actorSlug,
apifyToken: apifyClient.token || '',
limit: ACTOR_DETAILS_PICTURE_SEARCH_LIMIT,
}).catch(() => []),
]);
if (!actorInfo || !buildInfo || !buildInfo.actorDefinition) return null;

Expand Down
87 changes: 32 additions & 55 deletions src/utils/actor_search.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,64 @@
/**
* Shared utility for searching and filtering actors.
* Combines searchActorsByKeywords with filterRentalActors to prevent accidental omission
* of the filtering step and reduce code duplication.
* Shared utility for searching Actors via `GET /v2/store`.
*
* `GET /v2/store` returns only `[FREE, PAY_PER_EVENT]` Actors by default
* (apify-core's `AGENT_SAFE_PRICING_MODELS`) and additionally drops Actors
* that fail safety checks (KYC, full-permission low-usage, etc.) — so no
* MCP-side rental over-fetch / filter is needed.
*/

import { ApifyClient } from '../apify_client.js';
import { ACTOR_PRICING_MODEL } from '../const.js';
import type { PaymentProvider } from '../payments/types.js';
import type { ActorStoreList } from '../types.js';

/**
* 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.
*/
const ACTOR_SEARCH_ABOVE_LIMIT = 50;
type ActorPricingModel = (typeof ACTOR_PRICING_MODEL)[keyof typeof ACTOR_PRICING_MODEL];
export type SearchActorsByKeywordsOptions = {
search: string;
apifyToken: string;
limit: number;
offset?: number;
allowsAgenticUsers?: boolean;
/** API rejects values above `MAX_LIMIT_WITH_INPUT_SCHEMA` (apify-core cap). */
includeInputSchema?: boolean;
};

export type SearchAndFilterActorsOptions = {
export type SearchAgentSafeActorsOptions = {
keywords: string;
apifyToken: string;
limit: number;
offset: number;
paymentProvider?: PaymentProvider;
userRentedActorIds?: string[];
};

export async function searchActorsByKeywords(
search: string,
apifyToken: string,
limit: number | undefined = undefined,
offset: number | undefined = undefined,
allowsAgenticUsers: boolean | undefined = undefined,
options: SearchActorsByKeywordsOptions,
): Promise<ActorStoreList[]> {
const { search, apifyToken, limit, offset, allowsAgenticUsers, includeInputSchema } = options;
const client = new ApifyClient({ token: apifyToken });
const storeClient = client.store();
if (allowsAgenticUsers !== undefined) storeClient.params = { ...storeClient.params, allowsAgenticUsers };
if (includeInputSchema !== undefined) storeClient.params = { ...storeClient.params, includeInputSchema };

const results = await storeClient.list({ search, limit, offset });
return results.items as ActorStoreList[];
}

/**
* Search actors by keywords and filter rental actors.
* This combines two operations that should always happen together to ensure consistency.
*
* @param options Search and filter options
* @returns Array of filtered actors, limited to the specified limit
* Preset around `searchActorsByKeywords` for the agent-facing search tool:
* always sets `includeInputSchema=true` and forwards `allowsAgenticUsers`
* when a `paymentProvider` is in play. The public arg schema caps `limit`
* at apify-core's hard cap (`MAX_LIMIT_WITH_INPUT_SCHEMA`).
*/
export async function searchAndFilterActors(
options: SearchAndFilterActorsOptions,
export async function searchAgentSafeActors(
options: SearchAgentSafeActorsOptions,
): Promise<ActorStoreList[]> {
const { keywords, apifyToken, limit, offset, paymentProvider, userRentedActorIds } = options;
const { keywords, apifyToken, limit, offset, paymentProvider } = options;

const actors = await searchActorsByKeywords(
keywords,
return searchActorsByKeywords({
search: keywords,
apifyToken,
limit + ACTOR_SEARCH_ABOVE_LIMIT,
limit,
offset,
paymentProvider ? true : undefined,
);

return filterRentalActors(actors || [], userRentedActorIds || []).slice(0, limit) as ActorStoreList[];
}

/**
* 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).
*/
export function filterRentalActors(
actors: ActorStoreList[],
userRentedActorIds: string[],
): 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) => (
actor.currentPricingInfo.pricingModel as ActorPricingModel) !== ACTOR_PRICING_MODEL.FLAT_PRICE_PER_MONTH
|| userRentedActorIds.includes(actor.id),
);
allowsAgenticUsers: paymentProvider ? true : undefined,
includeInputSchema: true,
});
}
Loading
Loading