Skip to content

Commit 67c2372

Browse files
committed
fix: Gate call-actor mentions when the tool is absent from the session
Several places named call-actor unconditionally regardless of whether a session's ?tools=/?actors= selection actually included it \u2014 inviting a hallucinated call to a tool the client never received in tools/list. - actor_tools_factory.ts: gate the call-actor line in dedicated Actor tool descriptions via the existing hasTool pattern. - server-instructions/index.ts: getServerInstructions now takes a ToolDescriptionContext instead of a bare reportProblemAvailable boolean, gating every call-actor, rag-web-browser, web-fetch and report-problem mention through one mechanism. - mcp/server.ts: legacy path passes the real per-session tool set; stateless path resolves call-actor presence from the request URL (?tools=/?actors=) with zero network calls, since that presence never depends on mode, client identity, or the Actor-metadata fetch \u2014 falls back to today's behavior when no URL is given. - stateless_server.ts / dev_server.ts: thread requestUrl through, additive and backward compatible. Adds a regression test pinning the Claude-connector tool surface (19 tools, no call-actor) and asserting the resolved instructions never mention it.
1 parent d6f3a43 commit 67c2372

7 files changed

Lines changed: 229 additions & 69 deletions

File tree

src/dev_server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ async function serveStatelessRequest(req: Request, res: Response, taskStore: InM
197197
if (!isDiscoverProbe) {
198198
await mcpServer.loadToolsFromUrl(req.url, new ApifyClient({ token: apifyToken }));
199199
}
200-
return createStatelessServer(mcpServer);
200+
return createStatelessServer(mcpServer, req.url);
201201
},
202202
{
203203
legacy: 'reject',

src/mcp/server.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -270,23 +270,30 @@ export class ActorsMcpServer implements LegacyMcpServerHost, StatelessMcpServerH
270270
await this.resolveInstanceWidgets();
271271
}
272272

273-
/**
274-
* Server instructions for the current connection: mode plus whether report-problem is loaded.
275-
* Read by the legacy adapter after `applyInitialize`, when the tool set is final.
276-
*/
273+
/** Instructions for the current connection; `this.tools` is final here, after `applyInitialize`. */
277274
public getServerInstructions(): string {
278-
return getServerInstructions(this.serverMode, this.tools.has(HELPER_TOOLS.PROBLEM_REPORT));
275+
return getServerInstructions(this.serverMode, { hasTool: (name) => this.tools.has(name) });
279276
}
280277

281278
/**
282-
* Instructions for a stateless serving unit. The SDK answers `server/discover` from them before
283-
* any request's envelope is seen, so they are configuration-level: no report-problem mention
284-
* (that tool's presence is decided per request) and the configured mode only. Reads
285-
* `serverModeOption`, never `_serverMode` — one facade serves both eras, and a legacy
286-
* `initialize` rewrites `_serverMode`, which must not leak into later stateless requests.
279+
* Instructions for a stateless serving unit, answered from `server/discover` before any request
280+
* is seen — configuration-level only. Reads `serverModeOption`, never `_serverMode` (a legacy
281+
* `initialize` must not leak its mode into later stateless requests).
282+
*
283+
* `requestUrl`, when given, sharpens only `call-actor`: its presence resolves from
284+
* `?tools=`/`?actors=` with no fetch, unlike Actor tools or `report-problem` (identity-dependent).
285+
* Without a match, `call-actor` is assumed present — today's behavior, unchanged.
287286
*/
288-
public getStatelessServerInstructions(): string {
289-
return getServerInstructions(resolveServerMode(this.serverModeOption, false));
287+
public getStatelessServerInstructions(requestUrl?: string): string {
288+
const mode = resolveServerMode(this.serverModeOption, false);
289+
if (requestUrl === undefined) {
290+
return getServerInstructions(mode, { hasTool: (name) => name === HELPER_TOOLS.ACTOR_CALL });
291+
}
292+
const input = parseInputParamsFromUrl(requestUrl);
293+
const toolNames = new Set(getToolsForServerMode(input, [], mode).map((tool) => tool.name));
294+
return getServerInstructions(mode, {
295+
hasTool: (name) => name === HELPER_TOOLS.ACTOR_CALL && toolNames.has(name),
296+
});
290297
}
291298

292299
/**

src/mcp/stateless_server.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export interface StatelessMcpServerHost {
7575
readonly options: ActorsMcpServerOptions;
7676
readonly promptService: ReturnType<typeof createPromptService>;
7777
resolveApifyToken(meta?: ApifyRequestParams['_meta']): string | undefined;
78-
getStatelessServerInstructions(): string;
78+
getStatelessServerInstructions(requestUrl?: string): string;
7979
createRequestSnapshot(clientContext: McpClientContext | undefined): Promise<StatelessRequestSnapshot>;
8080
}
8181

@@ -125,7 +125,7 @@ class StatelessMcpServer {
125125
*/
126126
private snapshot: Promise<StatelessRequestSnapshot> | undefined;
127127

128-
constructor(host: StatelessMcpServerHost) {
128+
constructor(host: StatelessMcpServerHost, requestUrl?: string) {
129129
this.host = host;
130130
this.server = new Server(getServerInfo(), {
131131
capabilities: {
@@ -138,7 +138,7 @@ class StatelessMcpServer {
138138
resources: {},
139139
prompts: {},
140140
},
141-
instructions: this.host.getStatelessServerInstructions(),
141+
instructions: this.host.getStatelessServerInstructions(requestUrl),
142142
});
143143
this.setupToolHandlers();
144144
this.setupResourceHandlers();
@@ -391,7 +391,10 @@ async function emitLogServerSide(msg: { level: string; data?: unknown }): Promis
391391
* ```ts
392392
* const handler = createMcpHandler(() => createStatelessServer(actorsMcpServer), { legacy: 'reject' });
393393
* ```
394+
*
395+
* `requestUrl` sharpens the served instructions' `call-actor` mention; omit it for unchanged
396+
* behavior.
394397
*/
395-
export function createStatelessServer(host: StatelessMcpServerHost): Server {
396-
return new StatelessMcpServer(host).server;
398+
export function createStatelessServer(host: StatelessMcpServerHost, requestUrl?: string): Server {
399+
return new StatelessMcpServer(host, requestUrl).server;
397400
}

src/tools/actors/actor_tools_factory.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ import {
2323
type ActorStore,
2424
type ActorTool,
2525
type ApifyToken,
26+
type ToolDescriptionContext,
2627
type ToolEntry,
2728
type ToolInputSchema,
2829
ACTOR_TOOL_MODE,
30+
ALL_TOOLS_PRESENT,
2931
TOOL_TYPE,
3032
} from '../../types.js';
3133
import { getActorDefinitionCached } from '../../utils/actor.js';
@@ -125,14 +127,17 @@ export async function getNormalActorsAsTools(
125127
waitSecs: WAIT_SECS_INPUT_PROPERTY,
126128
};
127129

128-
let description = `This tool calls the Actor "${definition.actorFullName}" and retrieves its output results.
129-
Use this tool instead of the "${HELPER_TOOLS.ACTOR_CALL}" if user requests this specific Actor.
130-
Actor description: ${definition.description}`;
131-
if (isRag) {
132-
description += `\n\n${RAG_WEB_BROWSER_ADDITIONAL_DESC}`;
133-
} else if (definition.actorFullName === WEB_FETCH) {
134-
description += `\n\n${WEB_FETCH_ADDITIONAL_DESC}`;
135-
}
130+
// Names call-actor only when the session actually has it.
131+
const buildDescription = ({ hasTool }: ToolDescriptionContext): string => {
132+
let description = `This tool calls the Actor "${definition.actorFullName}" and retrieves its output results.
133+
${hasTool(HELPER_TOOLS.ACTOR_CALL) ? `Use this tool instead of the "${HELPER_TOOLS.ACTOR_CALL}" if user requests this specific Actor.\n` : ''}Actor description: ${definition.description}`;
134+
if (isRag) {
135+
description += `\n\n${RAG_WEB_BROWSER_ADDITIONAL_DESC}`;
136+
} else if (definition.actorFullName === WEB_FETCH) {
137+
description += `\n\n${WEB_FETCH_ADDITIONAL_DESC}`;
138+
}
139+
return description;
140+
};
136141

137142
const memoryMbytes = Math.min(
138143
definition.defaultRunOptions?.memoryMbytes || ACTOR_MAX_MEMORY_MBYTES,
@@ -160,7 +165,8 @@ Actor description: ${definition.description}`;
160165
title: definition.actorFullName,
161166
actorId: definition.id,
162167
actorFullName: definition.actorFullName,
163-
description,
168+
description: buildDescription(ALL_TOOLS_PRESENT),
169+
buildDescription,
164170
inputSchema: inputSchema as ToolInputSchema,
165171
// Canonical RunResponse shape — same as call-actor and get-actor-run.
166172
outputSchema: actorRunOutputSchema,

src/utils/server-instructions/index.ts

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@
99

1010
import { getApifyAPIBaseUrl } from '../../apify_client.js';
1111
import { HELPER_TOOLS, RAG_WEB_BROWSER, WEB_FETCH } from '../../const.js';
12-
import { SERVER_MODE } from '../../types.js';
12+
import { actorNameToToolName } from '../../tools/actor_tool_naming.js';
13+
import type { ToolDescriptionContext } from '../../types.js';
14+
import { ALL_TOOLS_PRESENT, SERVER_MODE } from '../../types.js';
15+
16+
// hasTool checks registered tool names, not Actor full names — see call_actor.ts's RAG_WEB_BROWSER_TOOL.
17+
const RAG_WEB_BROWSER_TOOL = actorNameToToolName(RAG_WEB_BROWSER);
18+
const WEB_FETCH_TOOL = actorNameToToolName(WEB_FETCH);
1319

1420
/**
15-
* Build server instructions for the given mode.
16-
*
17-
* Apps-only sections are omitted in default mode to prevent models from
18-
* attempting to call widget tools that are not registered. The report-problem line is
19-
* emitted only when `reportProblemAvailable` is true — i.e. `report-problem` is actually
20-
* served — so clients that never receive the tool (Anthropic surfaces, telemetry off, or a
21-
* `tools=` selection that omits report-problem) are not told to call it.
21+
* Build server instructions for the given mode. Every cross-tool mention is gated on
22+
* `ctx.hasTool(...)` so a session missing a tool is never told to call it.
2223
*/
23-
export function getServerInstructions(mode: SERVER_MODE = SERVER_MODE.DEFAULT, reportProblemAvailable = false): string {
24+
export function getServerInstructions(
25+
mode: SERVER_MODE = SERVER_MODE.DEFAULT,
26+
{ hasTool }: ToolDescriptionContext = ALL_TOOLS_PRESENT,
27+
): string {
2428
const isApps = mode === SERVER_MODE.APPS;
2529
// Derive the API base from config so examples match the gate/templates under an
2630
// APIFY_API_BASE_URL / staging override, instead of a hardcoded api.apify.com.
@@ -70,13 +74,20 @@ ${
7074
## Widget workflow (applies when tool responses include widget metadata)
7175
Some clients render widget-backed Actor tools: the response includes a live UI that automatically polls run status. When a widget is rendered, follow-up status polling by the model is a forbidden duplicate.
7276
73-
- **After \`${HELPER_TOOLS.ACTOR_CALL_WIDGET}\` or \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\`, never call \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` or \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` for the same run.** Both widgets render live progress and poll themselves — stop after the widget response and defer to it for run status. Re-rendering the same run via \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` is a duplicate.
77+
${
78+
hasTool(HELPER_TOOLS.ACTOR_CALL)
79+
? `- **After \`${HELPER_TOOLS.ACTOR_CALL_WIDGET}\` or \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\`, never call \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` or \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` for the same run.** Both widgets render live progress and poll themselves — stop after the widget response and defer to it for run status. Re-rendering the same run via \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` is a duplicate.
7480
- Polling \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` after \`${HELPER_TOOLS.ACTOR_CALL}\` is fine — that tool renders no UI, so polling is expected when the run is non-terminal and you need the latest status.
7581
`
82+
: `- **After \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\`, never call \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` or \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` for the same run.** It renders live progress and polls itself — stop after the widget response and defer to it for run status. Re-rendering the same run via \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` is a duplicate.
83+
`
84+
}`
7685
: ''
7786
}
7887
## Tool dependencies and disambiguation
79-
88+
${
89+
hasTool(HELPER_TOOLS.ACTOR_CALL)
90+
? `
8091
### Tool dependencies
8192
- \`${HELPER_TOOLS.ACTOR_CALL}\`:
8293
- Use \`${HELPER_TOOLS.ACTOR_GET_DETAILS}\` first to obtain the Actor's input schema.
@@ -85,7 +96,9 @@ Some clients render widget-backed Actor tools: the response includes a live UI t
8596
- Supports a \`waitSecs\` parameter (default 30, max 45):
8697
- \`waitSecs: 0\`: fire-and-forget — starts the run and returns immediately with a runId.
8798
- \`waitSecs > 0\`: waits up to that many seconds for the run to complete, then returns its current status and storage IDs (never the output rows — fetch those with \`${HELPER_TOOLS.DATASET_GET_ITEMS}\`).
88-
99+
`
100+
: ''
101+
}
89102
### Tool disambiguation
90103
- **\`${HELPER_TOOLS.STORE_SEARCH}\` vs \`${HELPER_TOOLS.ACTOR_GET_DETAILS}\`:**
91104
\`${HELPER_TOOLS.STORE_SEARCH}\` finds Actors; \`${HELPER_TOOLS.ACTOR_GET_DETAILS}\` retrieves detailed info, README, and schema for a specific Actor.
@@ -94,22 +107,33 @@ ${
94107
? `- **Data vs widget Actor tools (when the client supports widgets):**
95108
- \`${HELPER_TOOLS.STORE_SEARCH}\` is a silent data lookup (Actor list for name resolution) with no UI; \`${HELPER_TOOLS.STORE_SEARCH_WIDGET}\` renders an interactive UI element (widget) with Actor search results for the user to browse — use it only when the user explicitly asks to search or discover Actors.
96109
- \`${HELPER_TOOLS.ACTOR_GET_DETAILS}\` is a silent data lookup (input schema, README, metadata) with no UI; \`${HELPER_TOOLS.ACTOR_GET_DETAILS_WIDGET}\` renders an interactive UI element (widget) with Actor details — use it only when the user explicitly asks to see or browse the Actor.
97-
- \`${HELPER_TOOLS.ACTOR_CALL}\` runs the Actor and returns its run status and storage IDs (no UI); \`${HELPER_TOOLS.ACTOR_CALL_WIDGET}\` renders an interactive UI element (widget) that tracks live Actor run progress — use it only when the user explicitly asks to see progress.
98-
- \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` is a silent data lookup (run status, dataset IDs, stats) with no UI; \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` renders an interactive UI element (widget) showing live run progress for the user — use it only when the user explicitly asks to see run progress.
110+
${hasTool(HELPER_TOOLS.ACTOR_CALL) ? ` - \`${HELPER_TOOLS.ACTOR_CALL}\` runs the Actor and returns its run status and storage IDs (no UI); \`${HELPER_TOOLS.ACTOR_CALL_WIDGET}\` renders an interactive UI element (widget) that tracks live Actor run progress — use it only when the user explicitly asks to see progress.\n` : ''} - \`${HELPER_TOOLS.ACTOR_RUNS_GET}\` is a silent data lookup (run status, dataset IDs, stats) with no UI; \`${HELPER_TOOLS.ACTOR_RUNS_GET_WIDGET}\` renders an interactive UI element (widget) showing live run progress for the user — use it only when the user explicitly asks to see run progress.
99111
- When the next step is running an Actor, prefer silent lookups (\`${HELPER_TOOLS.STORE_SEARCH}\`, \`${HELPER_TOOLS.ACTOR_GET_DETAILS}\`) over widget-backed variants.
100112
`
101113
: ''
102-
}- **\`${HELPER_TOOLS.STORE_SEARCH}\` vs ${RAG_WEB_BROWSER}:**
114+
}${
115+
hasTool(RAG_WEB_BROWSER_TOOL)
116+
? `- **\`${HELPER_TOOLS.STORE_SEARCH}\` vs ${RAG_WEB_BROWSER}:**
103117
\`${HELPER_TOOLS.STORE_SEARCH}\` finds robust and reliable Actors for specific websites; ${RAG_WEB_BROWSER} is a general and versatile web scraping tool.
104-
- **${WEB_FETCH} vs ${RAG_WEB_BROWSER}:**
118+
`
119+
: ''
120+
}${
121+
hasTool(WEB_FETCH_TOOL) && hasTool(RAG_WEB_BROWSER_TOOL)
122+
? `- **${WEB_FETCH} vs ${RAG_WEB_BROWSER}:**
105123
${WEB_FETCH} fetches one specific URL and returns its full content verbatim; ${RAG_WEB_BROWSER} searches the web by query and returns content from the top results.
106-
- **Dedicated Actor tools (e.g. ${RAG_WEB_BROWSER}) vs \`${HELPER_TOOLS.ACTOR_CALL}\`:**
124+
`
125+
: ''
126+
}${
127+
hasTool(HELPER_TOOLS.ACTOR_CALL)
128+
? `- **Dedicated Actor tools${hasTool(RAG_WEB_BROWSER_TOOL) ? ` (e.g. ${RAG_WEB_BROWSER})` : ''} vs \`${HELPER_TOOLS.ACTOR_CALL}\`:**
107129
Prefer dedicated tools when available; use \`${HELPER_TOOLS.ACTOR_CALL}\` only when no specialized tool exists in the Apify store.
108-
${
109-
reportProblemAvailable
110-
? `
130+
`
131+
: ''
132+
}${
133+
hasTool(HELPER_TOOLS.PROBLEM_REPORT)
134+
? `
111135
If a tool or Actor fails and you cannot resolve it, you can report it with \`${HELPER_TOOLS.PROBLEM_REPORT}\`.
112136
`
113-
: ''
114-
}`;
137+
: ''
138+
}`;
115139
}

0 commit comments

Comments
 (0)