Skip to content

Commit 1a455a5

Browse files
jirispilkaclaude
andauthored
feat: Split fetch-actor-details into data + -widget tools (#722)
* feat: Split fetch-actor-details into data + -widget tools Decoupled-pattern pilot (#716, part of #577): - fetch-actor-details is now one mode-independent tool returning pure data. - New fetch-actor-details-widget (apps-only) renders the interactive UI element with only { actorDetails: { actorInfo, readme } }. - Removed fetch-actor-details-internal; the base tool now serves the silent lookup role. - Updated apps server instructions and search-actors/call-actor guidance to steer between silent data lookups and widget rendering. https://claude.ai/code/session_01ZsYYZ6u64jAWyxj3GTPwpG * refactor: Rename methods for clarity, refine initialize handler, and improve server instructions - Renamed `detectClientSupportsUi` to `isUiSupportedByClient` for better readability and alignment with other methods. - Updated initialize handler to include mode-aware server instructions in response payload. - Enhanced `getServerInstructions` to dynamically include apps-specific sections based on server mode, avoiding irrelevant content for default-mode clients. * feat: Move fetch-actor-details-widget and update related structures * refactor: Update widget tool metadata references after splitting fetch-actor-details * docs: Address Copilot review — strip-vs-reject wording, stale apps-variant refs, actorCard in widget contract - `fetchActorDetailsWidgetArgsSchema` comment now reflects that AJV `removeAdditional: true` silently strips stray keys (Zod `.strict()` is belt-and-braces for any path that bypasses AJV). - `fetchActorDetailsToolArgsSchema` / `fetchActorDetailsMetadata` docstrings no longer claim a separate "apps variant" exists — the base tool is mode-independent and the `-widget` sibling has its own schema/metadata. - Widget response test docstring + it-title + assertions now match the actual `{ actorDetails: { actorInfo, actorCard, readme } }` contract. https://claude.ai/code/session_01ZsYYZ6u64jAWyxj3GTPwpG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dcd5cdc commit 1a455a5

18 files changed

Lines changed: 352 additions & 181 deletions

src/const.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export enum HelperTools {
2626
ACTOR_ADD = 'add-actor',
2727
ACTOR_CALL = 'call-actor',
2828
ACTOR_GET_DETAILS = 'fetch-actor-details',
29-
ACTOR_GET_DETAILS_INTERNAL = 'fetch-actor-details-internal',
29+
ACTOR_GET_DETAILS_WIDGET = 'fetch-actor-details-widget',
3030
ACTOR_OUTPUT_GET = 'get-actor-output',
3131
ACTOR_RUNS_ABORT = 'abort-actor-run',
3232
ACTOR_RUNS_GET = 'get-actor-run',

src/tools/apps/call_actor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { callActorOutputSchema } from '../structured_output_schemas.js';
1717

1818
const CALL_ACTOR_APPS_DESCRIPTION = buildCallActorDescription({
19-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS_INTERNAL,
19+
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
2020
storeSearchTool: HelperTools.STORE_SEARCH_INTERNAL,
2121
useInternalSearchWarning: true,
2222
alwaysAsync: true,
@@ -90,7 +90,7 @@ export const appsCallActor: ToolEntry = Object.freeze({
9090
actorId: resolvedActorId,
9191
isAsync: true,
9292
mcpSessionId: toolArgs.mcpSessionId,
93-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS_INTERNAL,
93+
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS,
9494
storeSearchTool: HelperTools.STORE_SEARCH_INTERNAL,
9595
});
9696
}

src/tools/apps/fetch_actor_details.ts

Lines changed: 0 additions & 70 deletions
This file was deleted.

src/tools/apps/fetch_actor_details_internal.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import dedent from 'dedent';
2+
import { z } from 'zod';
3+
4+
import { HelperTools } from '../../const.js';
5+
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
6+
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
7+
import {
8+
buildActorDetailsForWidget,
9+
buildCardOptions,
10+
fetchActorDetails,
11+
} from '../../utils/actor_details.js';
12+
import { compileSchema } from '../../utils/ajv.js';
13+
import { buildMCPResponse } from '../../utils/mcp.js';
14+
import { getUserInfoCached } from '../../utils/userid_cache.js';
15+
import { fixActorNameInputAndLog } from '../core/actor_tools_factory.js';
16+
import {
17+
actorDetailsOutputDefaults,
18+
buildActorNotFoundResponse,
19+
} from '../core/fetch_actor_details_common.js';
20+
import { actorDetailsWidgetOutputSchema } from '../structured_output_schemas.js';
21+
22+
const widgetConfig = getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS);
23+
24+
/**
25+
* Widget-only input: `actor` only. `additionalProperties: false` + AJV's
26+
* `removeAdditional: true` means stray keys like `output` are silently stripped
27+
* at the server boundary; the `.strict()` Zod parse below is belt-and-braces
28+
* for any path that bypasses AJV.
29+
*/
30+
const fetchActorDetailsWidgetArgsSchema = z.object({
31+
actor: z.string()
32+
.min(1)
33+
.describe('Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".'),
34+
}).strict();
35+
36+
const FETCH_ACTOR_DETAILS_WIDGET_DESCRIPTION = dedent`
37+
Render an interactive UI element (widget) displaying detailed Actor information for the user.
38+
39+
Use this tool ONLY when the user explicitly wants to see or browse Actor details
40+
(e.g., "show me apify/rag-web-browser", "tell me about this Actor", "what does apify/web-scraper look like").
41+
The response renders as an interactive widget the user can view directly.
42+
43+
For silent data lookups (e.g., fetching the input schema before calling an Actor, inspecting README
44+
for decision making), use ${HelperTools.ACTOR_GET_DETAILS} instead — it returns the same data
45+
without rendering a widget.
46+
47+
Input: the Actor ID or full name only. Output fields are fixed by the widget contract.
48+
`;
49+
50+
export const fetchActorDetailsWidgetTool: ToolEntry = Object.freeze({
51+
type: 'internal',
52+
name: HelperTools.ACTOR_GET_DETAILS_WIDGET,
53+
description: FETCH_ACTOR_DETAILS_WIDGET_DESCRIPTION,
54+
inputSchema: z.toJSONSchema(fetchActorDetailsWidgetArgsSchema) as ToolInputSchema,
55+
outputSchema: actorDetailsWidgetOutputSchema,
56+
ajvValidate: compileSchema(z.toJSONSchema(fetchActorDetailsWidgetArgsSchema)),
57+
// Tool-level widget meta; only registered in apps mode so stripWidgetMeta is a no-op here.
58+
_meta: {
59+
...widgetConfig?.meta,
60+
},
61+
annotations: {
62+
title: 'Fetch Actor details (widget)',
63+
readOnlyHint: true,
64+
destructiveHint: false,
65+
idempotentHint: true,
66+
openWorldHint: false,
67+
},
68+
call: async (toolArgs: InternalToolArgs) => {
69+
const { apifyToken, apifyClient, mcpSessionId } = toolArgs;
70+
const parsed = fetchActorDetailsWidgetArgsSchema.parse(toolArgs.args);
71+
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route: HelperTools.ACTOR_GET_DETAILS_WIDGET });
72+
73+
const { userPlanTier } = await getUserInfoCached(apifyToken, apifyClient);
74+
const cardOptions = { ...buildCardOptions(actorDetailsOutputDefaults), userTier: userPlanTier };
75+
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
76+
if (!details) {
77+
return buildActorNotFoundResponse(actorName);
78+
}
79+
80+
const { actorUrl, actorDetails } = buildActorDetailsForWidget(details, userPlanTier);
81+
const structuredContent = {
82+
actorDetails: {
83+
actorInfo: actorDetails.actorInfo,
84+
actorCard: actorDetails.actorCard,
85+
readme: actorDetails.readme,
86+
},
87+
};
88+
89+
const texts = [dedent`
90+
# Actor information:
91+
- **Actor:** ${actorName}
92+
- **URL:** ${actorUrl}
93+
94+
An interactive widget has been rendered with detailed Actor information.
95+
`];
96+
97+
return buildMCPResponse({
98+
texts,
99+
structuredContent,
100+
// Response-level meta; only returned in apps mode (this handler is apps-only).
101+
_meta: {
102+
...widgetConfig?.meta,
103+
'openai/widgetDescription': `Actor details for ${actorName} from Apify Store`,
104+
},
105+
});
106+
},
107+
} as const);

src/tools/apps/search_actors.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ export const appsSearchActors: ToolEntry = Object.freeze({
4848
query: parsed.keywords,
4949
count: actors.length,
5050
instructions: dedent`
51-
Choosing the right details tool: Use ${HelperTools.ACTOR_GET_DETAILS} when the user
52-
wants to browse or explore Actors (e.g., "show me", "find me").
53-
Use ${HelperTools.ACTOR_GET_DETAILS_INTERNAL} when the user wants to execute a task and
54-
you need the input schema (e.g., "scrape", "extract").
51+
Choosing the right details tool: Use ${HelperTools.ACTOR_GET_DETAILS_WIDGET} when the user
52+
wants to see or browse Actor details — it renders an interactive UI element (widget) for the user
53+
(e.g., "show me", "tell me about this Actor").
54+
Use ${HelperTools.ACTOR_GET_DETAILS} for silent data lookups (input schema, README, metadata)
55+
when preparing an Actor run or making a decision (e.g., "scrape", "extract") — no UI is rendered.
5556
IMPORTANT: You MUST always do a second search with broader, more generic keywords
5657
(e.g., just the platform name like "TikTok" instead of "TikTok posts") to make sure
5758
you haven't missed a better Actor.
@@ -75,12 +76,12 @@ export const appsSearchActors: ToolEntry = Object.freeze({
7576
${actorCardText}
7677
7778
## Choosing the right details tool:
78-
- Use ${HelperTools.ACTOR_GET_DETAILS} when the user wants to **browse or explore**
79-
Actors (e.g., "show me Google Maps scrapers", "find me a TikTok scraper", "what Actors
80-
exist for LinkedIn"). This renders an interactive widget for the user.
81-
- Use ${HelperTools.ACTOR_GET_DETAILS_INTERNAL} when the user wants to **execute a task**
82-
and you need the Actor's input schema to prepare the run (e.g., "scrape Google Maps for
83-
restaurants", "extract emails from this website"). This is a silent lookup — no widget
79+
- Use ${HelperTools.ACTOR_GET_DETAILS_WIDGET} when the user wants to **see or browse**
80+
an Actor (e.g., "show me apify/rag-web-browser", "tell me about this Actor"). This renders
81+
an **interactive UI element (widget)** the user can view directly.
82+
- Use ${HelperTools.ACTOR_GET_DETAILS} for **silent data lookups** — fetching the input
83+
schema to prepare a run, reading the README for decision making, or inspecting metadata
84+
(e.g., "scrape Google Maps for restaurants", "extract emails from this website"). No UI
8485
is rendered.
8586
8687
IMPORTANT: You MUST always do a second search with broader, more generic keywords

src/tools/categories.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
import type { ToolEntry } from '../types.js';
1717
import { ServerMode } from '../types.js';
1818
import { appsCallActor } from './apps/call_actor.js';
19-
import { appsFetchActorDetails } from './apps/fetch_actor_details.js';
20-
import { fetchActorDetailsInternalTool } from './apps/fetch_actor_details_internal.js';
19+
import { fetchActorDetailsWidgetTool } from './apps/fetch_actor_details_widget.js';
2120
import { appsGetActorRun } from './apps/get_actor_run.js';
2221
import { appsSearchActors } from './apps/search_actors.js';
2322
import { searchActorsInternalTool } from './apps/search_actors_internal.js';
@@ -70,12 +69,12 @@ export const toolCategories = {
7069
],
7170
actors: [
7271
{ default: defaultSearchActors, apps: appsSearchActors },
73-
{ default: defaultFetchActorDetails, apps: appsFetchActorDetails },
72+
defaultFetchActorDetails,
7473
{ default: defaultCallActor, apps: appsCallActor },
7574
],
7675
ui: [
7776
{ apps: searchActorsInternalTool },
78-
{ apps: fetchActorDetailsInternalTool },
77+
{ apps: fetchActorDetailsWidgetTool },
7978
],
8079
docs: [
8180
searchApifyDocsTool,

src/tools/core/call_actor_common.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const CALL_ACTOR_EXAMPLES_SECTION = `EXAMPLES:
4848
- user_input: Get instagram posts using apify/instagram-scraper`;
4949

5050
type CallActorDescriptionParams = {
51-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS | HelperTools.ACTOR_GET_DETAILS_INTERNAL;
51+
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS;
5252
storeSearchTool: HelperTools.STORE_SEARCH | HelperTools.STORE_SEARCH_INTERNAL;
5353
useInternalSearchWarning: boolean;
5454
alwaysAsync: boolean;
@@ -78,7 +78,7 @@ type CallActorErrorResponseParams = {
7878
actorId?: string;
7979
isAsync: boolean;
8080
mcpSessionId?: string;
81-
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS | HelperTools.ACTOR_GET_DETAILS_INTERNAL;
81+
actorGetDetailsTool: HelperTools.ACTOR_GET_DETAILS;
8282
storeSearchTool: HelperTools.STORE_SEARCH | HelperTools.STORE_SEARCH_INTERNAL;
8383
};
8484

src/tools/core/fetch_actor_details_common.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import dedent from 'dedent';
22
import { z } from 'zod';
33

44
import { FAILURE_CATEGORY, HelperTools, TOOL_STATUS } from '../../const.js';
5-
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
65
import type { HelperTool, InternalToolArgs, ToolInputSchema } from '../../types.js';
76
import {
87
type ActorDetailsResult,
@@ -77,7 +76,9 @@ export function resolveOutputOptions(output?: z.infer<typeof actorDetailsOutputO
7776
}
7877

7978
/**
80-
* Zod schema for fetch-actor-details arguments — shared between default and apps variants.
79+
* Zod schema for fetch-actor-details arguments — used by the mode-independent
80+
* base tool. The `-widget` sibling has its own `actor`-only schema in
81+
* `src/tools/apps/fetch_actor_details_widget.ts`.
8182
*/
8283
export const fetchActorDetailsToolArgsSchema = z.object({
8384
actor: z.string()
@@ -103,8 +104,9 @@ EXAMPLES:
103104
- What tools does apify/actors-mcp-server provide?`;
104105

105106
/**
106-
* Shared tool metadata for fetch-actor-details — everything except the `call` handler.
107-
* Used by both default and apps variants.
107+
* Tool metadata for the mode-independent `fetch-actor-details` — everything
108+
* except the `call` handler. No widget `_meta`; the `-widget` sibling (apps-only)
109+
* carries its own widget metadata.
108110
*/
109111
export const fetchActorDetailsMetadata: Omit<HelperTool, 'call'> = {
110112
type: 'internal',
@@ -113,10 +115,6 @@ export const fetchActorDetailsMetadata: Omit<HelperTool, 'call'> = {
113115
inputSchema: z.toJSONSchema(fetchActorDetailsToolArgsSchema) as ToolInputSchema,
114116
outputSchema: actorDetailsOutputSchema,
115117
ajvValidate: compileSchema(z.toJSONSchema(fetchActorDetailsToolArgsSchema)),
116-
// openai/* and ui keys are stripped in non-apps mode by stripWidgetMeta() in src/utils/tools.ts
117-
_meta: {
118-
...getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS)?.meta,
119-
},
120118
annotations: {
121119
title: 'Fetch Actor details',
122120
readOnlyHint: true,
@@ -217,16 +215,15 @@ export function buildActorDetailsTextResponse(options: {
217215
}
218216

219217
/**
220-
* Shared handler for default and internal fetch-actor-details variants.
221-
* Both return the same text + structured response; only the telemetry route differs.
218+
* Shared handler for the base fetch-actor-details tool.
219+
* Returns the same text + structured response in both modes.
222220
*/
223221
export async function buildFetchActorDetailsResult(
224222
toolArgs: InternalToolArgs,
225-
route: HelperTools.ACTOR_GET_DETAILS | HelperTools.ACTOR_GET_DETAILS_INTERNAL,
226223
): Promise<ReturnType<typeof buildMCPResponse>> {
227224
const { args, apifyToken, apifyClient, apifyMcpServer, mcpSessionId } = toolArgs;
228225
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
229-
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route });
226+
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route: HelperTools.ACTOR_GET_DETAILS });
230227

231228
const resolvedOutput = resolveOutputOptions(parsed.output);
232229
// Skip the /users/me round-trip when pricing isn't rendered (e.g. inputSchema-only

src/tools/default/fetch_actor_details.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { HelperTools } from '../../const.js';
21
import type { ToolEntry } from '../../types.js';
32
import {
43
buildFetchActorDetailsResult,
@@ -11,5 +10,5 @@ import {
1110
*/
1211
export const defaultFetchActorDetails: ToolEntry = Object.freeze({
1312
...fetchActorDetailsMetadata,
14-
call: async (toolArgs) => buildFetchActorDetailsResult(toolArgs, HelperTools.ACTOR_GET_DETAILS),
13+
call: async (toolArgs) => buildFetchActorDetailsResult(toolArgs),
1514
} as const);

0 commit comments

Comments
 (0)