Skip to content

Commit 5f31e83

Browse files
committed
feat: Add input field summaries to Actor search results
Include input field names, types, and required status in search-actors results so LLMs can prepare Actor calls without a separate fetch-actor-details round-trip. - Fetch Actor build definitions in parallel (concurrency=10) after search - Check actorDefinitionPrunedCache + dedicated inputFieldsCache for hits - Display as TypeScript-like notation: name: type, name?: type - Add reusable runWithConcurrency utility to src/utils/generic.ts - Add constants rule to CLAUDE.md
1 parent 9e8d09b commit 5f31e83

16 files changed

Lines changed: 290 additions & 213 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Breaking changes must be coordinated; check whether updates are needed in `apify
7171

7272
- **Validate tool inputs with Zod.** No ad-hoc shape checks.
7373
- **Reference tool names via the `HelperTools` enum**, not hardcoded strings (exception: integration tests).
74+
- **Put constants in `src/const.ts`**, not as local variables in individual files. This includes cache sizes/TTLs, concurrency limits, magic numbers, and string enums.
7475
- Always follow the latest [MCP spec](https://modelcontextprotocol.io/specification/2025-11-25) and [MCP Apps spec](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).
7576

7677
## Further reading

src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export const MCP_SERVER_CACHE_MAX_SIZE = 500;
102102
export const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes
103103
export const USER_CACHE_MAX_SIZE = 200;
104104
export const USER_CACHE_TTL_SECS = 60 * 60; // 1 hour
105+
export const INPUT_SCHEMA_FETCH_CONCURRENCY = 10;
105106

106107
export const ACTOR_PRICING_MODEL = {
107108
/** Rental Actors */

src/state.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ import {
66
MCP_SERVER_CACHE_MAX_SIZE,
77
MCP_SERVER_CACHE_TTL_SECS,
88
} from './const.js';
9-
import type { ActorDefinitionWithInfo, ApifyDocsSearchResult } from './types.js';
9+
import type { ActorDefinitionWithInfo, ActorInputField, ApifyDocsSearchResult } from './types.js';
1010
import { TTLLRUCache } from './utils/ttl_lru.js';
1111

1212
export const actorDefinitionPrunedCache = new TTLLRUCache<ActorDefinitionWithInfo>(ACTOR_CACHE_MAX_SIZE, ACTOR_CACHE_TTL_SECS);
13+
/**
14+
* Lightweight cache for extracted input field summaries, keyed by actor fullName.
15+
* Separate from actorDefinitionPrunedCache because that cache requires ActorDefinitionWithInfo
16+
* (both definition + actor info), and actor info needs an extra actor.get() API call
17+
* that we want to avoid during search enrichment.
18+
*/
19+
export const inputFieldsCache = new TTLLRUCache<ActorInputField[]>(ACTOR_CACHE_MAX_SIZE, ACTOR_CACHE_TTL_SECS);
1320
export const searchApifyDocsCache = new TTLLRUCache<ApifyDocsSearchResult[]>(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS);
1421
/** Stores processed Markdown content */
1522
export const fetchApifyDocsCache = new TTLLRUCache<string>(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS);

src/tools/core/search_actors_common.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from 'zod';
33

44
import { HelperTools } from '../../const.js';
55
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
6-
import type { ActorStoreList, HelperTool, StructuredActorCard, ToolInputSchema } from '../../types.js';
6+
import type { ActorInputField, ActorStoreList, HelperTool, StructuredActorCard, ToolInputSchema } from '../../types.js';
77
import { DEFAULT_CARD_OPTIONS, formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
88
import { compileSchema } from '../../utils/ajv.js';
99
import { buildMCPResponse } from '../../utils/mcp.js';
@@ -78,7 +78,7 @@ Usage:
7878
- Prefer broad, generic keywords - use just the platform name (e.g. "Instagram" instead of "Instagram scraper").
7979
- You MUST always do at least two searches: first with broad keywords, then optionally with more specific terms if needed.
8080
81-
Important limitations: This tool does not return full Actor documentation, input schemas, or detailed usage instructions - only summary information.
81+
Important limitations: This tool does not return full Actor documentation or detailed usage instructions - only summary information including input field names and types.
8282
For complete Actor details, use the ${HelperTools.ACTOR_GET_DETAILS} tool.
8383
The search is limited to publicly available Actors and may not include private, rental, or restricted Actors depending on the user's access level.
8484
@@ -124,11 +124,18 @@ export type SearchActorsResult = {
124124
export function buildSearchActorsResult(
125125
actors: ActorStoreList[],
126126
userTier: PricingTier,
127+
inputFieldsMap?: Map<string, ActorInputField[]>,
127128
): SearchActorsResult {
128-
const options = { ...DEFAULT_CARD_OPTIONS, userTier, simplifyPricingForUserTier: true };
129+
const baseOptions = { ...DEFAULT_CARD_OPTIONS, userTier, simplifyPricingForUserTier: true };
129130
return {
130-
actorCardText: actors.map((actor) => formatActorToActorCard(actor, options)).join('\n\n'),
131-
actorCardStructured: actors.map((actor) => formatActorToStructuredCard(actor, options)),
131+
actorCardText: actors.map((actor) => {
132+
const options = { ...baseOptions, inputFields: inputFieldsMap?.get(`${actor.username}/${actor.name}`) };
133+
return formatActorToActorCard(actor, options);
134+
}).join('\n\n'),
135+
actorCardStructured: actors.map((actor) => {
136+
const options = { ...baseOptions, inputFields: inputFieldsMap?.get(`${actor.username}/${actor.name}`) };
137+
return formatActorToStructuredCard(actor, options);
138+
}),
132139
};
133140
}
134141

src/tools/default/search_actors.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import dedent from 'dedent';
22

33
import { HelperTools } from '../../const.js';
44
import type { InternalToolArgs, ToolEntry } from '../../types.js';
5-
import { searchAndFilterActors } from '../../utils/actor_search.js';
5+
import { fetchInputFieldsForActors, searchAndFilterActors } from '../../utils/actor_search.js';
66
import { buildMCPResponse } from '../../utils/mcp.js';
77
import { getUserInfoCached } from '../../utils/userid_cache.js';
88
import {
@@ -39,7 +39,8 @@ export const defaultSearchActors: ToolEntry = Object.freeze({
3939
return buildSearchActorsEmptyResponse(parsed.keywords);
4040
}
4141

42-
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
42+
const inputFieldsMap = await fetchInputFieldsForActors(actors, apifyClient);
43+
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier, inputFieldsMap);
4344
const structuredContent = {
4445
actors: actorCardStructured,
4546
query: parsed.keywords,

src/tools/openai/search_actors.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HelperTools } from '../../const.js';
44
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
55
import type { InternalToolArgs, ToolEntry } from '../../types.js';
66
import { formatActorForWidget, type WidgetActor } from '../../utils/actor_card.js';
7-
import { searchAndFilterActors } from '../../utils/actor_search.js';
7+
import { fetchInputFieldsForActors, searchAndFilterActors } from '../../utils/actor_search.js';
88
import { buildMCPResponse } from '../../utils/mcp.js';
99
import { getUserInfoCached } from '../../utils/userid_cache.js';
1010
import { buildSearchActorsEmptyResponse, buildSearchActorsResult, searchActorsArgsSchema, searchActorsMetadata } from '../core/search_actors_common.js';
@@ -36,7 +36,8 @@ export const openaiSearchActors: ToolEntry = Object.freeze({
3636
return buildSearchActorsEmptyResponse(parsed.keywords);
3737
}
3838

39-
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
39+
const inputFieldsMap = await fetchInputFieldsForActors(actors, apifyClient);
40+
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier, inputFieldsMap);
4041
const structuredContent: {
4142
actors: typeof actorCardStructured;
4243
query: string;

src/tools/structured_output_schemas.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ export const actorInfoSchema = {
141141
},
142142
modifiedAt: { type: 'string', description: 'Last modification date' },
143143
isDeprecated: { type: 'boolean', description: 'Whether the Actor is deprecated' },
144+
inputFields: {
145+
type: 'array' as const,
146+
items: {
147+
type: 'object' as const,
148+
properties: {
149+
name: { type: 'string', description: 'Input field name' },
150+
type: { type: 'string', description: 'Input field type' },
151+
required: { type: 'boolean', description: 'Whether the field is required' },
152+
},
153+
required: ['name', 'type', 'required'],
154+
},
155+
description: 'Input field summaries (name, type, required). Uses raw property keys for LLM consumption.',
156+
},
144157
},
145158
// Note: `pricing` is not required. openai/fetch-actor-details intentionally omits it from
146159
// `actorInfo` so the widget's tier-aware pricing (under `actorDetails.actorInfo.currentPricingInfo`)

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,12 @@ export type ActorsMcpServerOptions = {
573573
uiMode?: UiMode;
574574
}
575575

576+
export type ActorInputField = {
577+
name: string;
578+
type: string;
579+
required: boolean;
580+
};
581+
576582
export type StructuredActorCard = {
577583
title?: string;
578584
url: string;
@@ -599,6 +605,7 @@ export type StructuredActorCard = {
599605
};
600606
modifiedAt?: string;
601607
isDeprecated: boolean;
608+
inputFields?: ActorInputField[];
602609
}
603610

604611
/**
@@ -623,6 +630,13 @@ export type ActorCardOptions = {
623630
* false/undefined → keep the full tiered matrix (fetch-actor-details).
624631
*/
625632
simplifyPricingForUserTier?: boolean;
633+
/**
634+
* Input field summaries to include in the card (name, type, required).
635+
* This is actor-specific enrichment data fetched from the Actor's build definition,
636+
* not a display toggle. Uses raw property keys (not titles) since the primary
637+
* consumer is an LLM that needs exact keys to construct valid Actor input JSON.
638+
*/
639+
inputFields?: ActorInputField[];
626640
}
627641

628642
/**

src/utils/actor_card.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ export function formatActorToActorCard(
217217
markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.');
218218
}
219219
}
220+
if (options.inputFields?.length) {
221+
const fields = options.inputFields
222+
.map((f) => `${f.name}${f.required ? '' : '?'}: ${f.type}`)
223+
.join(', ');
224+
markdownLines.push(`- **Input fields:** ${fields}`);
225+
}
220226
return markdownLines.join('\n');
221227
}
222228

@@ -249,6 +255,7 @@ export function formatActorToStructuredCard(
249255
rating: data.rating,
250256
modifiedAt: data.modifiedAt,
251257
isDeprecated: data.isDeprecated,
258+
inputFields: options.inputFields,
252259
};
253260
}
254261

src/utils/actor_search.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
*/
66

77
import { ApifyClient } from '../apify_client.js';
8-
import { ACTOR_PRICING_MODEL } from '../const.js';
8+
import { ACTOR_PRICING_MODEL, INPUT_SCHEMA_FETCH_CONCURRENCY } from '../const.js';
99
import type { PaymentProvider } from '../payments/types.js';
10-
import type { ActorStoreList } from '../types.js';
10+
import { actorDefinitionPrunedCache, inputFieldsCache } from '../state.js';
11+
import type { ActorInputField, ActorInputSchema, ActorStoreList } from '../types.js';
12+
import { runWithConcurrency } from './generic.js';
13+
import { logHttpError } from './logging.js';
1114

1215
/**
1316
* Used in search Actors tool to search above the input supplied limit,
@@ -85,3 +88,93 @@ export function filterRentalActors(
8588
|| userRentedActorIds.includes(actor.id),
8689
);
8790
}
91+
92+
/**
93+
* Extract minimal field info (name, type, required) from an ActorInputSchema.
94+
*/
95+
export function extractInputFields(input: ActorInputSchema): ActorInputField[] {
96+
const requiredSet = new Set(input.required ?? []);
97+
return Object.entries(input.properties ?? {}).map(([name, prop]) => ({
98+
name,
99+
type: prop.type ?? 'unknown',
100+
required: requiredSet.has(name),
101+
}));
102+
}
103+
104+
/**
105+
* Look up cached input fields for an actor by fullName.
106+
* Checks actorDefinitionPrunedCache for a cached definition with input schema.
107+
* Returns null on cache miss.
108+
*/
109+
function getCachedInputFields(fullName: string): ActorInputField[] | null {
110+
// Check dedicated input fields cache first
111+
const cached = inputFieldsCache.get(fullName);
112+
if (cached) return cached;
113+
114+
// Fall back to full actor definition cache (populated by fetch-actor-details / call-actor)
115+
const cachedDef = actorDefinitionPrunedCache.get(fullName);
116+
if (cachedDef?.definition?.input) {
117+
const fields = extractInputFields(cachedDef.definition.input);
118+
inputFieldsCache.set(fullName, fields);
119+
return fields;
120+
}
121+
return null;
122+
}
123+
124+
/**
125+
* Fetch input schema fields for a single actor from the API.
126+
* Returns null on failure.
127+
*/
128+
async function fetchInputFieldsForActor(
129+
fullName: string,
130+
apifyClient: ApifyClient,
131+
): Promise<ActorInputField[] | null> {
132+
try {
133+
const buildClient = await apifyClient.actor(fullName).defaultBuild();
134+
const build = await buildClient.get();
135+
if (build?.actorDefinition?.input) {
136+
const input = build.actorDefinition.input as ActorInputSchema;
137+
const fields = extractInputFields(input);
138+
inputFieldsCache.set(fullName, fields);
139+
return fields;
140+
}
141+
return null;
142+
} catch (error) {
143+
logHttpError(error, `Failed to fetch input schema for '${fullName}'`, { actorName: fullName });
144+
return null;
145+
}
146+
}
147+
148+
/**
149+
* Fetch input schemas for a list of actors with bounded concurrency.
150+
* Checks caches for hits, fetches misses from API.
151+
* Returns a Map<actorFullName, ActorInputField[]>.
152+
*/
153+
export async function fetchInputFieldsForActors(
154+
actors: ActorStoreList[],
155+
apifyClient: ApifyClient,
156+
): Promise<Map<string, ActorInputField[]>> {
157+
const result = new Map<string, ActorInputField[]>();
158+
159+
// Resolve cache hits first
160+
const toFetch: { fullName: string }[] = [];
161+
for (const actor of actors) {
162+
const fullName = `${actor.username}/${actor.name}`;
163+
const cached = getCachedInputFields(fullName);
164+
if (cached) {
165+
result.set(fullName, cached);
166+
} else {
167+
toFetch.push({ fullName });
168+
}
169+
}
170+
171+
// Fetch cache misses with bounded concurrency
172+
await runWithConcurrency(toFetch, INPUT_SCHEMA_FETCH_CONCURRENCY, async ({ fullName }) => {
173+
const fields = await fetchInputFieldsForActor(fullName, apifyClient);
174+
if (fields) {
175+
result.set(fullName, fields);
176+
}
177+
});
178+
179+
return result;
180+
}

0 commit comments

Comments
 (0)