Skip to content

Commit b2d350e

Browse files
jirispilkaclaude
andauthored
refactor: Simplify fetch-actor-details — deduplicate handler, colocate schemas, clean utils (#694)
* refactor: Deduplicate fetch-actor-details handler logic Extract shared handler body from default and internal variants into buildFetchActorDetailsResult in fetch_actor_details_common.ts. Apply dedent to multiline strings. Remove duplicated args schema from internal tool. https://claude.ai/code/session_01BdYJoEQKitqiF1i8g1XAxv * chore: Update package-lock.json https://claude.ai/code/session_01BdYJoEQKitqiF1i8g1XAxv * refactor: Narrow route param type to HelperTools enum Use HelperTools.ACTOR_GET_DETAILS / ACTOR_GET_DETAILS_INTERNAL instead of loosely-typed string for the route parameter of buildFetchActorDetailsResult. * refactor: Simplify buildActorDetailsTextResponse signature Reduce from 9 params to 4 by making it pure/sync. The caller now pre-resolves mcpToolsMessage when output.mcpTools is true, so the apifyClient/apifyToken/paymentProvider/mcpSessionId/actorName parameters are no longer needed inside the formatter. Drop redundant cardOptions param (derivable from output). Extract ResolvedOutputOptions type from actorDetailsOutputDefaults. * refactor: Colocate fetch-actor-details schemas and response builders Move tool-specific items from src/utils/actor_details.ts to src/tools/core/fetch_actor_details_common.ts — matching the structure of src/tools/core/search_actors_common.ts: - actorDetailsOutputOptionsSchema - actorDetailsOutputDefaults - ResolvedOutputOptions - resolveOutputOptions - buildActorNotFoundResponse - buildActorDetailsTextResponse utils/actor_details.ts now holds only shared data-fetching and formatting utilities (fetchActorDetails, getMcpToolsMessage, processActorDetailsForResponse, resolveReadmeContent, buildCardOptions, typeObjectToString, ActorDetailsResult). Export typeObjectToString since buildActorDetailsTextResponse (now in tools/core) still uses it. * refactor: Apply dedent / texts[] array convention to long strings Per CONTRIBUTING.md, replace long single-line templates with \n escapes using the appropriate pattern for each case: - dedent for multi-line strings with safe interpolations (buildActorNotFoundResponse, Output Schema TypeScript block, No output schema fallback) - texts[] array joined with \n for strings that embed JSON.stringify or multi-line interpolations (input schema block, per-tool MCP formatting in getMcpToolsMessage, and its final wrapper with mcpToolsInfo) * refactor: Simplify actor_details utils - typeObjectToString / typeValueToString: collapse 35 lines of mutual recursion into 13; preserve top-level skip behavior via Object.entries().filter(). Behavior equivalence verified by 17 new unit tests. - fetchActorDetails: cache apifyClient.actor(actorName) (was called twice), hoist actorSlug, reformat Promise.all cleanly (the third entry had been mangled by a prior lint auto-fix). - processActorDetailsForResponse → rename to buildActorDetailsForWidget (better describes what it does). Drop dead code: the returned `texts` array and top-level `formattedReadme` were never consumed. Flatten structuredContent wrapper: caller immediately peeled it. * refactor: Clarify test description for nested array type handling * fix: Review comments --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7335a53 commit b2d350e

7 files changed

Lines changed: 1244 additions & 1014 deletions

File tree

package-lock.json

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

src/tools/core/fetch_actor_details_common.ts

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,80 @@
1+
import dedent from 'dedent';
12
import { z } from 'zod';
23

3-
import { HelperTools } from '../../const.js';
4+
import { ApifyClient } from '../../apify_client.js';
5+
import { FAILURE_CATEGORY, HelperTools, TOOL_STATUS } from '../../const.js';
46
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
5-
import type { HelperTool, ToolInputSchema } from '../../types.js';
7+
import type { HelperTool, InternalToolArgs, ToolInputSchema } from '../../types.js';
68
import {
7-
actorDetailsOutputOptionsSchema,
9+
type ActorDetailsResult,
10+
buildCardOptions,
11+
fetchActorDetails,
12+
getMcpToolsMessage,
13+
resolveReadmeContent,
14+
typeObjectToString,
815
} from '../../utils/actor_details.js';
916
import { compileSchema } from '../../utils/ajv.js';
17+
import { buildMCPResponse } from '../../utils/mcp.js';
1018
import { actorDetailsOutputSchema } from '../structured_output_schemas.js';
19+
import { fixActorNameInputAndLog } from './actor_tools_factory.js';
20+
21+
/**
22+
* Shared schema for actor details output options.
23+
*
24+
* Behavior:
25+
* - If output is undefined or empty object: use defaults (all true except mcpTools and outputSchema)
26+
* - If any property is explicitly set: only include sections with explicit true values
27+
*/
28+
export const actorDetailsOutputOptionsSchema = z.object({
29+
description: z.boolean().optional().describe('Include Actor description text only.'),
30+
stats: z.boolean().optional().describe('Include usage statistics (users, runs, success rate).'),
31+
pricing: z.boolean().optional().describe('Include pricing model and costs.'),
32+
rating: z.boolean().optional().describe('Include user rating (out of 5 stars).'),
33+
metadata: z.boolean().optional().describe('Include developer, categories, last modified date, and deprecation status.'),
34+
inputSchema: z.boolean().optional().describe('Include required input parameters schema.'),
35+
readme: z.boolean().optional().describe('Include Actor README documentation (summary when available, full otherwise).'),
36+
outputSchema: z.boolean().optional().describe('Include inferred output schema from recent successful runs (TypeScript type).'),
37+
mcpTools: z.boolean().optional().describe('List available tools (only for MCP server Actors).'),
38+
});
39+
40+
export const actorDetailsOutputDefaults = {
41+
description: true,
42+
stats: true,
43+
pricing: true,
44+
rating: true,
45+
metadata: true,
46+
inputSchema: true,
47+
readme: true,
48+
outputSchema: false,
49+
mcpTools: false,
50+
};
51+
52+
export type ResolvedOutputOptions = typeof actorDetailsOutputDefaults;
53+
54+
/**
55+
* Resolve output options with smart defaults.
56+
* If output is undefined/empty, returns defaults.
57+
* If any property is explicitly set, undefined properties are treated as false.
58+
*/
59+
export function resolveOutputOptions(output?: z.infer<typeof actorDetailsOutputOptionsSchema>): ResolvedOutputOptions {
60+
const hasExplicitOptions = output && Object.values(output).some((v) => v !== undefined);
61+
62+
if (!hasExplicitOptions) {
63+
return actorDetailsOutputDefaults;
64+
}
65+
66+
return {
67+
description: output?.description === true,
68+
stats: output?.stats === true,
69+
pricing: output?.pricing === true,
70+
rating: output?.rating === true,
71+
metadata: output?.metadata === true,
72+
inputSchema: output?.inputSchema === true,
73+
readme: output?.readme === true,
74+
outputSchema: output?.outputSchema === true,
75+
mcpTools: output?.mcpTools === true,
76+
};
77+
}
1178

1279
/**
1380
* Zod schema for fetch-actor-details arguments — shared between default and openai variants.
@@ -58,3 +125,134 @@ export const fetchActorDetailsMetadata: Omit<HelperTool, 'call'> = {
58125
openWorldHint: false,
59126
},
60127
};
128+
129+
/**
130+
* Build error response for when actor is not found.
131+
*/
132+
export function buildActorNotFoundResponse(actorName: string): ReturnType<typeof buildMCPResponse> {
133+
return buildMCPResponse({
134+
texts: [dedent`
135+
Actor information for '${actorName}' was not found.
136+
Please verify Actor ID or name format and ensure that the Actor exists.
137+
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
138+
`],
139+
isError: true,
140+
telemetry: { toolStatus: TOOL_STATUS.SOFT_FAIL, failureCategory: FAILURE_CATEGORY.INVALID_INPUT },
141+
});
142+
}
143+
144+
/**
145+
* Build text and structured response for actor details.
146+
* Pure/sync: the caller pre-resolves `mcpToolsMessage` when `output.mcpTools` is true.
147+
*/
148+
export function buildActorDetailsTextResponse(options: {
149+
details: ActorDetailsResult;
150+
output: ResolvedOutputOptions;
151+
actorOutputSchema?: Record<string, unknown> | null;
152+
mcpToolsMessage?: string;
153+
}): {
154+
texts: string[];
155+
structuredContent: Record<string, unknown>;
156+
} {
157+
const { details, output, actorOutputSchema, mcpToolsMessage } = options;
158+
159+
const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`;
160+
161+
const texts: string[] = [];
162+
163+
const needsCard = output.description
164+
|| output.stats
165+
|| output.pricing
166+
|| output.rating
167+
|| output.metadata;
168+
169+
if (needsCard) {
170+
texts.push(`# Actor information\n${details.actorCard}`);
171+
}
172+
173+
const resolvedReadme = output.readme ? resolveReadmeContent(details) : undefined;
174+
if (resolvedReadme) {
175+
texts.push(`${resolvedReadme.heading}\n${resolvedReadme.content}`);
176+
}
177+
178+
if (output.inputSchema) {
179+
texts.push([
180+
`# [Input schema](${actorUrl}/input)`,
181+
'```json',
182+
JSON.stringify(details.inputSchema),
183+
'```',
184+
].join('\n'));
185+
}
186+
187+
if (output.outputSchema) {
188+
if (actorOutputSchema && Object.keys(actorOutputSchema).length > 0) {
189+
const typeString = typeObjectToString(actorOutputSchema);
190+
texts.push(dedent`
191+
# Output Schema (TypeScript)
192+
Inferred from recent successful runs:
193+
\`\`\`typescript
194+
type ActorOutput = ${typeString}
195+
\`\`\`
196+
`);
197+
} else {
198+
texts.push(dedent`
199+
# Output Schema
200+
No output schema available. The Actor may not have recent successful runs, or the output structure could not be determined.
201+
`);
202+
}
203+
}
204+
205+
if (mcpToolsMessage) {
206+
texts.push(mcpToolsMessage);
207+
}
208+
209+
const structuredContent: Record<string, unknown> = {
210+
actorInfo: needsCard ? details.actorCardStructured : undefined,
211+
readme: resolvedReadme?.content,
212+
inputSchema: output.inputSchema ? details.inputSchema : undefined,
213+
outputSchema: output.outputSchema ? (actorOutputSchema ?? {}) : undefined,
214+
};
215+
216+
return { texts, structuredContent };
217+
}
218+
219+
/**
220+
* Shared handler for default and internal fetch-actor-details variants.
221+
* Both return the same text + structured response; only the telemetry route differs.
222+
*/
223+
export async function buildFetchActorDetailsResult(
224+
toolArgs: InternalToolArgs,
225+
route: HelperTools.ACTOR_GET_DETAILS | HelperTools.ACTOR_GET_DETAILS_INTERNAL,
226+
): Promise<ReturnType<typeof buildMCPResponse>> {
227+
const { args, apifyToken, apifyMcpServer, mcpSessionId } = toolArgs;
228+
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
229+
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route });
230+
const apifyClient = new ApifyClient({ token: apifyToken });
231+
232+
const resolvedOutput = resolveOutputOptions(parsed.output);
233+
const details = await fetchActorDetails(apifyClient, actorName, buildCardOptions(resolvedOutput));
234+
if (!details) {
235+
return buildActorNotFoundResponse(actorName);
236+
}
237+
238+
let actorOutputSchema: Record<string, unknown> | null | undefined;
239+
if (resolvedOutput.outputSchema) {
240+
actorOutputSchema = apifyMcpServer.actorStore
241+
? await apifyMcpServer.actorStore.getActorOutputSchemaAsTypeObject(actorName).catch(() => null)
242+
: null;
243+
}
244+
const mcpToolsMessage = resolvedOutput.mcpTools
245+
? await getMcpToolsMessage(actorName, apifyClient, apifyToken, apifyMcpServer?.options.paymentProvider, mcpSessionId)
246+
: undefined;
247+
248+
// NOTE: Data duplication between texts and structuredContent is intentional and required.
249+
// Some MCP clients only read text content, while others only read structured content.
250+
const { texts, structuredContent } = buildActorDetailsTextResponse({
251+
details,
252+
output: resolvedOutput,
253+
actorOutputSchema,
254+
mcpToolsMessage,
255+
});
256+
257+
return buildMCPResponse({ texts, structuredContent });
258+
}
Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
import { ApifyClient } from '../../apify_client.js';
2-
import type { InternalToolArgs, ToolEntry } from '../../types.js';
3-
import {
4-
buildActorDetailsTextResponse,
5-
buildActorNotFoundResponse,
6-
buildCardOptions,
7-
fetchActorDetails,
8-
resolveOutputOptions,
9-
} from '../../utils/actor_details.js';
10-
import { buildMCPResponse } from '../../utils/mcp.js';
11-
import { fixActorNameInputAndLog } from '../core/actor_tools_factory.js';
1+
import { HelperTools } from '../../const.js';
2+
import type { ToolEntry } from '../../types.js';
123
import {
4+
buildFetchActorDetailsResult,
135
fetchActorDetailsMetadata,
14-
fetchActorDetailsToolArgsSchema,
156
} from '../core/fetch_actor_details_common.js';
167

178
/**
@@ -20,39 +11,5 @@ import {
2011
*/
2112
export const defaultFetchActorDetails: ToolEntry = Object.freeze({
2213
...fetchActorDetailsMetadata,
23-
call: async (toolArgs: InternalToolArgs) => {
24-
const { args, apifyToken, apifyMcpServer, mcpSessionId } = toolArgs;
25-
const parsedArgs = fetchActorDetailsToolArgsSchema.parse(args);
26-
const actorName = fixActorNameInputAndLog(parsedArgs.actor, { mcpSessionId, route: 'fetch-actor-details' });
27-
const apifyClient = new ApifyClient({ token: apifyToken });
28-
29-
const resolvedOutput = resolveOutputOptions(parsedArgs.output);
30-
const cardOptions = buildCardOptions(resolvedOutput);
31-
32-
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
33-
if (!details) {
34-
return buildActorNotFoundResponse(actorName);
35-
}
36-
37-
// Fetch output schema from ActorStore if available and requested
38-
const actorOutputSchema = resolvedOutput.outputSchema
39-
? await apifyMcpServer.actorStore?.getActorOutputSchemaAsTypeObject(actorName).catch(() => null)
40-
: undefined;
41-
42-
// NOTE: Data duplication between texts and structuredContent is intentional and required.
43-
// Some MCP clients only read text content, while others only read structured content.
44-
const { texts, structuredContent: responseStructuredContent } = await buildActorDetailsTextResponse({
45-
actorName,
46-
details,
47-
output: resolvedOutput,
48-
cardOptions,
49-
apifyClient,
50-
apifyToken,
51-
actorOutputSchema,
52-
paymentProvider: apifyMcpServer?.options.paymentProvider,
53-
mcpSessionId,
54-
});
55-
56-
return buildMCPResponse({ texts, structuredContent: responseStructuredContent });
57-
},
14+
call: async (toolArgs) => buildFetchActorDetailsResult(toolArgs, HelperTools.ACTOR_GET_DETAILS),
5815
} as const);

src/tools/openai/fetch_actor_details.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1+
import dedent from 'dedent';
2+
13
import { ApifyClient } from '../../apify_client.js';
24
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
35
import type { InternalToolArgs, ToolEntry } from '../../types.js';
46
import {
5-
buildActorNotFoundResponse,
7+
buildActorDetailsForWidget,
68
buildCardOptions,
79
fetchActorDetails,
8-
processActorDetailsForResponse,
9-
resolveOutputOptions,
1010
} from '../../utils/actor_details.js';
1111
import { buildMCPResponse } from '../../utils/mcp.js';
1212
import { fixActorNameInputAndLog } from '../core/actor_tools_factory.js';
1313
import {
14+
buildActorNotFoundResponse,
1415
fetchActorDetailsMetadata,
1516
fetchActorDetailsToolArgsSchema,
17+
resolveOutputOptions,
1618
} from '../core/fetch_actor_details_common.js';
1719

1820
/**
@@ -27,28 +29,26 @@ export const openaiFetchActorDetails: ToolEntry = Object.freeze({
2729
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route: 'fetch-actor-details' });
2830
const apifyClient = new ApifyClient({ token: apifyToken });
2931

30-
const resolvedOutput = resolveOutputOptions(parsed.output);
31-
const cardOptions = buildCardOptions(resolvedOutput);
32-
32+
const cardOptions = buildCardOptions(resolveOutputOptions(parsed.output));
3333
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
3434
if (!details) {
3535
return buildActorNotFoundResponse(actorName);
3636
}
3737

38-
const { structuredContent: processedStructuredContent, actorUrl } = processActorDetailsForResponse(details);
38+
const { actorUrl, actorDetails } = buildActorDetailsForWidget(details);
3939
const structuredContent = {
4040
actorInfo: details.actorCardStructured,
4141
inputSchema: details.inputSchema,
42-
actorDetails: processedStructuredContent.actorDetails,
42+
actorDetails,
4343
};
4444

45-
const texts = [`
46-
# Actor information:
47-
- **Actor:** ${actorName}
48-
- **URL:** ${actorUrl}
45+
const texts = [dedent`
46+
# Actor information:
47+
- **Actor:** ${actorName}
48+
- **URL:** ${actorUrl}
4949
50-
An interactive widget has been rendered with detailed Actor information.
51-
`];
50+
An interactive widget has been rendered with detailed Actor information.
51+
`];
5252

5353
const widgetConfig = getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS);
5454
return buildMCPResponse({

0 commit comments

Comments
 (0)