Skip to content

Commit 5d2035f

Browse files
authored
feat: add outputSchema option to fetch-actor-details tools (#413)
* feat: add outputSchema option to fetch-actor-details tools - Add outputSchema boolean option to actorDetailsOutputOptionsSchema - Pass actorOutputSchema through meta from internal server - Add typeObjectToString utility for human-readable type display - Include outputSchema in structured response content - Update fetch-actor-details and fetch-actor-details-internal tools * feat: add previewOutput option to call-actor tool and fix outputSchema type ## Summary - Add optional `previewOutput` parameter (default: true) to call-actor tool for controlling dataset preview in responses - Fix `outputSchema` field type in actorDetailsOutputSchema from string to object - Reduce context clutter when users plan to fetch specific fields via get-actor-output tool ## Technical Changes - Add `previewOutput` boolean parameter to callActorArgs Zod schema with contextual description - Pass `previewOutput` through callActorGetDataset to conditionally skip preview items extraction - Update buildActorResponseContent to handle both preview modes with appropriate messaging - Change actorDetailsOutputSchema.outputSchema type from 'string' to 'object' to match actual data - Add 2 integration tests for previewOutput feature (true and false modes) ## Backward Compatibility - previewOutput defaults to true, maintaining existing behavior - No breaking changes to public API
1 parent 94fdac3 commit 5d2035f

10 files changed

Lines changed: 146 additions & 9 deletions

File tree

src/mcp/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ export class ActorsMcpServer {
617617
const metaApifyToken = meta?.apifyToken;
618618
const apifyToken = (metaApifyToken || this.options.token || process.env.APIFY_TOKEN) as string;
619619
const userRentedActorIds = meta?.userRentedActorIds;
620+
const actorOutputSchema = meta?.actorOutputSchema;
620621
// mcpSessionId was injected upstream it is important and required for long running tasks as the store uses it and there is not other way to pass it
621622
const mcpSessionId = meta?.mcpSessionId;
622623
if (!mcpSessionId) {
@@ -764,6 +765,7 @@ Please remove the "task" parameter from the tool call request or use a different
764765
mcpServer: this.server,
765766
apifyToken,
766767
userRentedActorIds,
768+
actorOutputSchema,
767769
progressTracker,
768770
}) as object;
769771

src/tools/actor.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export async function callActorGetDataset(
6565
callOptions: ActorCallOptions | undefined = undefined,
6666
progressTracker?: ProgressTracker | null,
6767
abortSignal?: AbortSignal,
68+
previewOutput = true,
6869
): Promise<CallActorGetDatasetResult | null> {
6970
const CLIENT_ABORT = Symbol('CLIENT_ABORT'); // Just internal symbol to identify client abort
7071
const actorClient = apifyClient.actor(actorName);
@@ -123,7 +124,9 @@ export async function callActorGetDataset(
123124
*/
124125
const storageDefinition = defaultBuild?.actorDefinition?.storages?.dataset as ActorDefinitionStorage | undefined;
125126
const importantProperties = getActorDefinitionStorageFieldNames(storageDefinition || {});
126-
const previewItems = ensureOutputWithinCharLimit(datasetItems.items, importantProperties, TOOL_MAX_OUTPUT_CHARS);
127+
const previewItems = previewOutput
128+
? ensureOutputWithinCharLimit(datasetItems.items, importantProperties, TOOL_MAX_OUTPUT_CHARS)
129+
: [];
127130

128131
return {
129132
runId: actorRun.id,
@@ -345,6 +348,9 @@ For MCP server Actors, use format "actorName:toolName" to call a specific tool (
345348
async: z.boolean()
346349
.optional()
347350
.describe(`When true: starts the run and returns immediately with runId. When false or not provided: waits for completion and returns results immediately. Default: true when UI mode is enabled (enforced), false otherwise. Note: When UI mode is enabled, async is always true regardless of this parameter and the widget automatically tracks progress.`),
351+
previewOutput: z.boolean()
352+
.optional()
353+
.describe('When true (default): includes preview items. When false: metadata only (reduces context). Use when fetching fields via get-actor-output.'),
348354
callOptions: z.object({
349355
memory: z.number()
350356
.min(128, 'Memory must be at least 128 MB')
@@ -432,7 +438,7 @@ export const callActor: ToolEntry = {
432438
},
433439
call: async (toolArgs: InternalToolArgs) => {
434440
const { args, apifyToken, progressTracker, extra, apifyMcpServer } = toolArgs;
435-
const { actor: actorName, input, async, callOptions } = callActorArgs.parse(args);
441+
const { actor: actorName, input, async, previewOutput = true, callOptions } = callActorArgs.parse(args);
436442

437443
// Parse special format: actor:tool
438444
const mcpToolMatch = actorName.match(/^(.+):(.+)$/);
@@ -605,6 +611,7 @@ Do NOT proactively poll using ${HelperTools.ACTOR_RUNS_GET}. Wait for the widget
605611
callOptions,
606612
progressTracker,
607613
extra.signal,
614+
previewOutput,
608615
);
609616

610617
if (!callResult) {
@@ -613,7 +620,7 @@ Do NOT proactively poll using ${HelperTools.ACTOR_RUNS_GET}. Wait for the widget
613620
return {};
614621
}
615622

616-
const content = buildActorResponseContent(actorName, callResult);
623+
const content = buildActorResponseContent(actorName, callResult, previewOutput);
617624

618625
return { content };
619626
} catch (error) {

src/tools/fetch-actor-details-internal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ but the user did NOT explicitly ask for Actor details presentation.`,
4949
openWorldHint: false,
5050
},
5151
call: async (toolArgs: InternalToolArgs) => {
52-
const { args, apifyToken, apifyMcpServer } = toolArgs;
52+
const { args, apifyToken, apifyMcpServer, actorOutputSchema } = toolArgs;
5353
const parsed = fetchActorDetailsInternalArgsSchema.parse(args);
5454
const apifyClient = new ApifyClient({ token: apifyToken });
5555

@@ -68,6 +68,7 @@ but the user did NOT explicitly ask for Actor details presentation.`,
6868
cardOptions,
6969
apifyClient,
7070
apifyToken,
71+
actorOutputSchema,
7172
skyfireMode: apifyMcpServer?.options.skyfireMode,
7273
});
7374

src/tools/fetch-actor-details.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ EXAMPLES:
5454
openWorldHint: false,
5555
},
5656
call: async (toolArgs: InternalToolArgs) => {
57-
const { args, apifyToken, apifyMcpServer } = toolArgs;
57+
const { args, apifyToken, apifyMcpServer, actorOutputSchema } = toolArgs;
5858
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
5959
const apifyClient = new ApifyClient({ token: apifyToken });
6060

@@ -106,6 +106,7 @@ An interactive widget has been rendered with detailed Actor information.
106106
cardOptions,
107107
apifyClient,
108108
apifyToken,
109+
actorOutputSchema,
109110
skyfireMode: apifyMcpServer?.options.skyfireMode,
110111
});
111112

src/tools/structured-output-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const actorDetailsOutputSchema = {
128128
actorInfo: actorInfoSchema,
129129
readme: { type: 'string', description: 'Actor README documentation.' },
130130
inputSchema: { type: 'object' as const, description: 'Actor input schema.' }, // Literal type required for MCP SDK type compatibility
131+
outputSchema: { type: 'object' as const, description: 'Output schema inferred from successful runs.' },
131132
},
132133
};
133134

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export type InternalToolArgs = {
128128
apifyToken: string;
129129
/** List of Actor IDs that the user has rented */
130130
userRentedActorIds?: string[];
131+
/** Actor output schema as type object (injected by internal server) */
132+
actorOutputSchema?: Record<string, unknown> | null;
131133
/** Optional progress tracker for long running internal tools, like call-actor */
132134
progressTracker?: ProgressTracker | null;
133135
};
@@ -459,6 +461,8 @@ export type ApifyRequestParams = {
459461
apifyToken?: string;
460462
/** List of Actor IDs that the user has rented */
461463
userRentedActorIds?: string[];
464+
/** Actor output schema as type object (injected by internal server) */
465+
actorOutputSchema?: Record<string, unknown> | null;
462466
/** Progress token for out-of-band progress notifications (standard MCP) */
463467
progressToken?: string | number;
464468
/** Allow other metadata fields */

src/utils/actor-details.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ import { formatActorToActorCard, formatActorToStructuredCard } from './actor-car
1111
import { logHttpError } from './logging.js';
1212
import { buildMCPResponse } from './mcp.js';
1313

14+
/**
15+
* Convert a type object to TypeScript-like string representation.
16+
* Used for human-readable text output.
17+
*
18+
* Example:
19+
* Input: { first_number: "number", tags: ["string"], user: { name: "string" } }
20+
* Output: "{ first_number: number, tags: string[], user: { name: string } }"
21+
*/
22+
function typeObjectToString(obj: Record<string, unknown>): string {
23+
const pairs: string[] = [];
24+
25+
for (const [key, value] of Object.entries(obj)) {
26+
if (Array.isArray(value)) {
27+
// Array type
28+
const itemType = typeValueToString(value[0]);
29+
pairs.push(`${key}: ${itemType}[]`);
30+
} else if (typeof value === 'object' && value !== null) {
31+
// Nested object type
32+
const nestedStr = typeObjectToString(value as Record<string, unknown>);
33+
pairs.push(`${key}: ${nestedStr}`);
34+
} else if (typeof value === 'string') {
35+
// Primitive type
36+
pairs.push(`${key}: ${value}`);
37+
}
38+
}
39+
40+
return `{ ${pairs.join(', ')} }`;
41+
}
42+
43+
/**
44+
* Convert a single type value to string.
45+
*/
46+
function typeValueToString(value: unknown): string {
47+
if (Array.isArray(value)) {
48+
const itemType = typeValueToString(value[0]);
49+
return `${itemType}[]`;
50+
} if (typeof value === 'object' && value !== null) {
51+
return typeObjectToString(value as Record<string, unknown>);
52+
} if (typeof value === 'string') {
53+
return value;
54+
}
55+
return 'unknown';
56+
}
57+
1458
// Keep the type here since it is a self-contained module
1559
export type ActorDetailsResult = {
1660
actorInfo: Actor;
@@ -98,7 +142,7 @@ export function processActorDetailsForResponse(details: ActorDetailsResult) {
98142
* Used by both public and internal fetch-actor-details tools.
99143
*
100144
* Behavior:
101-
* - If output is undefined or empty object: use defaults (all true except mcpTools)
145+
* - If output is undefined or empty object: use defaults (all true except mcpTools and outputSchema)
102146
* - If any property is explicitly set: only include sections with explicit true values
103147
*/
104148
export const actorDetailsOutputOptionsSchema = z.object({
@@ -109,6 +153,7 @@ export const actorDetailsOutputOptionsSchema = z.object({
109153
metadata: z.boolean().optional().describe('Include developer, categories, last modified date, and deprecation status.'),
110154
inputSchema: z.boolean().optional().describe('Include required input parameters schema.'),
111155
readme: z.boolean().optional().describe('Include full README documentation.'),
156+
outputSchema: z.boolean().optional().describe('Include inferred output schema from recent successful runs (TypeScript type).'),
112157
mcpTools: z.boolean().optional().describe('List available tools (only for MCP server Actors).'),
113158
});
114159

@@ -120,6 +165,7 @@ export const actorDetailsOutputDefaults = {
120165
metadata: true,
121166
inputSchema: true,
122167
readme: true,
168+
outputSchema: false,
123169
mcpTools: false,
124170
};
125171

@@ -145,6 +191,7 @@ export function resolveOutputOptions(output?: z.infer<typeof actorDetailsOutputO
145191
metadata: output?.metadata === true,
146192
inputSchema: output?.inputSchema === true,
147193
readme: output?.readme === true,
194+
outputSchema: output?.outputSchema === true,
148195
mcpTools: output?.mcpTools === true,
149196
};
150197
}
@@ -224,7 +271,7 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
224271

225272
/**
226273
* Build text and structured response for actor details.
227-
* Handles all resolved output options: description, stats, readme, inputSchema, mcpTools.
274+
* Handles all resolved output options: description, stats, readme, inputSchema, outputSchema, mcpTools.
228275
* All output properties should be boolean (resolved via resolveOutputOptions).
229276
*/
230277
export async function buildActorDetailsTextResponse(options: {
@@ -238,17 +285,19 @@ export async function buildActorDetailsTextResponse(options: {
238285
metadata: boolean;
239286
readme: boolean;
240287
inputSchema: boolean;
288+
outputSchema: boolean;
241289
mcpTools: boolean;
242290
};
243291
cardOptions: ActorCardOptions;
244292
apifyClient: ApifyClient;
245293
apifyToken: string;
294+
actorOutputSchema?: Record<string, unknown> | null;
246295
skyfireMode?: boolean;
247296
}): Promise<{
248297
texts: string[];
249298
structuredContent: Record<string, unknown>;
250299
}> {
251-
const { actorName, details, output, cardOptions, apifyClient, apifyToken, skyfireMode } = options;
300+
const { actorName, details, output, cardOptions, apifyClient, apifyToken, actorOutputSchema, skyfireMode } = options;
252301

253302
const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`;
254303
const formattedReadme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `);
@@ -276,6 +325,16 @@ export async function buildActorDetailsTextResponse(options: {
276325
texts.push(`# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\``);
277326
}
278327

328+
// Add output schema if requested
329+
if (output.outputSchema) {
330+
if (actorOutputSchema && Object.keys(actorOutputSchema).length > 0) {
331+
const typeString = typeObjectToString(actorOutputSchema);
332+
texts.push(`# Output Schema (TypeScript)\nInferred from recent successful runs:\n\`\`\`typescript\ntype ActorOutput = ${typeString}\n\`\`\``);
333+
} else {
334+
texts.push(`# Output Schema\nNo output schema available. The Actor may not have recent successful runs, or the output structure could not be determined.`);
335+
}
336+
}
337+
279338
// Handle MCP tools
280339
if (output.mcpTools) {
281340
const message = await getMcpToolsMessage(actorName, apifyClient, apifyToken, skyfireMode);
@@ -287,6 +346,7 @@ export async function buildActorDetailsTextResponse(options: {
287346
actorInfo: needsCard ? details.actorCardStructured : undefined,
288347
readme: output.readme ? formattedReadme : undefined,
289348
inputSchema: output.inputSchema ? details.inputSchema : undefined,
349+
outputSchema: output.outputSchema ? actorOutputSchema : undefined,
290350
};
291351

292352
return { texts, structuredContent };

src/utils/actor-response.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import type { CallActorGetDatasetResult } from '../tools/actor.js';
1313
*
1414
* @param actorName - The name of the actor.
1515
* @param result - The result from callActorGetDataset.
16+
* @param previewOutput - Whether to include preview items (default: true).
1617
* @returns The content array for the tool response.
1718
*/
1819
export function buildActorResponseContent(
1920
actorName: string,
2021
result: CallActorGetDatasetResult,
22+
previewOutput = true,
2123
): ({ type: 'text'; text: string })[] {
2224
const { runId, datasetId, itemCount, schema } = result;
2325

@@ -46,9 +48,16 @@ Above this text block is a preview of the Actor output containing ${result.previ
4648
If you need to retrieve additional data, use the "get-actor-output" tool with: datasetId: "${datasetId}". Be sure to limit the number of results when using the "get-actor-output" tool, since you never know how large the items may be, and they might exceed the output limits.
4749
`;
4850

51+
const getEmptyPreviewMessage = () => {
52+
if (previewOutput) {
53+
return `No items available for preview—either the Actor did not return any items or they are too large for preview. Use the "get-actor-output" tool with datasetId: "${result.datasetId}" to retrieve results.`;
54+
}
55+
return `Preview skipped (previewOutput: false). Use the "get-actor-output" tool with datasetId: "${result.datasetId}" to retrieve results or specific fields.`;
56+
};
57+
4958
const itemsPreviewText = result.previewItems.length > 0
5059
? JSON.stringify(result.previewItems)
51-
: `No items available for preview—either the Actor did not return any items or they are too large for preview. In this case, use the "get-actor-output" tool.`;
60+
: getEmptyPreviewMessage();
5261

5362
// Build content array
5463
return [

src/web/package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)