Skip to content

Commit 1f421e1

Browse files
jirispilkaclaude
andauthored
fix: Apply direct Actor input contracts consistently (#1585)
## Why Closes #1574. Closes #1575. Supersedes #1580 and #1581. WCC's API defaults robots.txt handling to false. Its direct MCP tool also accepts page code and session cookies, preventing read-only classification under the existing rule. ## What changed One declarative direct-tool policy controls defaults, denied inputs, and restriction text. The factory derives annotations from permitted inputs while retaining ownership, permission, and side-effect checks. For `apify--website-content-crawler`, robots.txt defaults to true and explicit false remains valid. `pageFunction` and `initialCookies` are absent from the offered properties, and the call engine refuses a call that carries either before validation runs, so no run starts even when AJV would otherwise strip an undeclared field. The served schema stays a plain object: OpenAI rejects a function schema with `allOf`, `anyOf`, `oneOf` or `not` at the top level, strict or not. Generic calls, widgets, and Actor details keep the original Actor input contract. Restricted direct-tool errors point to their own tools/list schema. Other Actors are unchanged. Contract change: WCC's direct input schema, description, validation, and annotations. Applies to the hosted server through the core package; no internal exports, shared types, result shapes, or client-specific routing changed. ## Notes for reviewers (human-written) ## Proof it works - New regression tests failed before implementation. Final suite: 2,661 passed, 1 skipped. Type-check, lint, format, check:agents, and build passed. - mcpc on the local build: WCC has read-only/non-destructive/open-world annotations; the three other direct tools retain their annotations. Both denied fields were refused with an error naming them, and no run started. Other validation errors point to tools/list. `fetch-actor-details` and a `call-actor` validation error still show the original schema with both fields and `default: false`. - The exact served WCC schema is accepted by OpenAI's Responses API (HTTP 200). The earlier `allOf` version was refused with HTTP 400 ("schema must have type 'object' and not have 'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level"). - One-page [Actor run](https://console.apify.com/actors/runs/iaftEn6QeCMUKorMX) succeeded with the robots.txt field omitted. `apify key-value-stores get-value qEgw9ZA6yIZ6u5WQh INPUT` confirmed `respectRobotsTxtFile: true`. A second one-page run on the final head (`ykzc28KDS26DtXPq1`) stored `respectRobotsTxtFile: true` in `vXvkNIfxOm7acBeWl` with neither denied field present. - Generic and widget regression tests use the real factory and validator, including lookup by Actor ID, to verify original inputs/defaults and error schemas. AI-assisted: Codex wrote the implementation, tests, documentation, and this description, ran verification, and used an independent review agent. Claude Code reviewed the PR, replaced the schema-level `allOf` denial with the call-engine check after finding the OpenAI rejection, and reran the mcpc checks and the second run. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c31ed5 commit 1f421e1

11 files changed

Lines changed: 335 additions & 12 deletions

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,11 @@ Legend for the **Enabled by default** column:
315315
All tools include metadata annotations to help MCP clients and LLMs understand tool behavior:
316316

317317
- **`title`**: Short display name for the tool (e.g., "Search Actors", "Call Actor", "apify/rag-web-browser")
318-
- **`readOnlyHint`**: `true` for tools that only read data without modifying state (e.g., `get-dataset`, `fetch-actor-details`). Actor tools get it only for Apify-maintained Actors with limited permissions and no secret, writable storage, MCP connector, or code input (e.g., `apify--web-fetch`); other Actor tools are marked destructive.
318+
- **`readOnlyHint`**: `true` for tools that only read data without modifying state (e.g., `get-dataset`, `fetch-actor-details`). Actor tools get it only for Apify-maintained Actors with limited permissions and no accepted secret, writable storage, MCP connector, or code input (e.g., `apify--web-fetch`). Direct tools can restrict the original Actor inputs: `apify--website-content-crawler` excludes and rejects `pageFunction` and `initialCookies`, so it is read-only. Other Actor tools are marked destructive.
319319
- **`openWorldHint`**: `true` for tools that access external resources outside the Apify platform (e.g., `call-actor` executes external Actors). Tools that interact only with the Apify platform (like `search-actors` or `fetch-apify-docs`) do not have this hint.
320320

321+
The direct `apify--website-content-crawler` tool defaults `respectRobotsTxtFile` to `true`; an explicit `false` is preserved. These restrictions and defaults apply only to the direct tool. `call-actor`, `call-actor-widget`, and `fetch-actor-details` retain the original Actor input contract.
322+
321323
### Tools configuration
322324

323325
The `tools` configuration parameter is used to specify loaded tools – either categories or specific tools directly, and Apify Actors. For example, `tools=storage,runs` loads two categories; `tools=call-actor` loads just one tool.

‎res/chatgpt-app-submission.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ their base tool.
4444
| Tool | readOnlyHint | destructiveHint | openWorldHint | Rationale |
4545
|---|---|---|---|---|
4646
| `call-actor` (both modes) | false | true | true | Runs Actors that can modify external state |
47-
| Dynamic Actor tools, read-only Apify-maintained Actors | true | false | true | Limited permissions, no secret, writable storage, MCP connector, or code input (`isReadOnlyActor`) |
47+
| Dynamic Actor tools, read-only Apify-maintained Actors | true | false | true | Limited permissions, no accepted secret, writable storage, MCP connector, or code input (`isReadOnlyActor`); WCC's direct contract excludes and rejects `pageFunction` and `initialCookies` |
4848
| Other dynamic Actor tools | false | true | true | Same as call-actor |
4949
| `abort-actor-run` | false | true | false | Irreversible abort within Apify platform |
5050
| `search-actors` | true | false | false | Searches Apify Store (read-only) |

‎src/mcp/tool_call_engine.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { prepareToolCallContext } from '../payments/helpers.js';
1414
import type { PaymentMeta, PaymentProvider, RequestHeaders } from '../payments/types.js';
1515
import { decodeDotPropertyNames } from '../tools/actor_input_schema.js';
1616
import { legacyToolNameToNew } from '../tools/actor_tool_naming.js';
17+
import {
18+
buildDeniedInputsNote,
19+
getDeniedDirectActorInputs,
20+
getDirectActorPolicy,
21+
} from '../tools/actors/actor_tool_contract.js';
1722
import { checkPaymentProviderStandbyConflict } from '../tools/actors/call_actor.js';
1823
import { withReportProblemNudge } from '../tools/dev/report_problem.js';
1924
import type { ActorStore, CallDiagnostics, ToolCallTelemetryProperties, ToolEntry, ToolStatus } from '../types.js';
@@ -175,9 +180,11 @@ export async function prepareToolCall(params: {
175180

176181
const actorName = extractActorName(tool, args as Record<string, unknown>);
177182
const actorId = extractActorId(tool);
178-
// fetch-actor-details only describes Actors, and only helps if this session serves it.
183+
// Actor details describe the original schema; restricted direct tools publish their own in tools/list.
179184
const schemaHint =
180-
tool.type === TOOL_TYPE.ACTOR && tools.has(HELPER_TOOLS.ACTOR_GET_DETAILS)
185+
tool.type === TOOL_TYPE.ACTOR &&
186+
!getDirectActorPolicy(tool.actorFullName) &&
187+
tools.has(HELPER_TOOLS.ACTOR_GET_DETAILS)
181188
? `using ${HELPER_TOOLS.ACTOR_GET_DETAILS} tool`
182189
: 'in tools/list';
183190

@@ -225,6 +232,20 @@ export async function prepareToolCall(params: {
225232
mcpSessionId: params.mcpSessionId,
226233
input: logSafeArgs,
227234
});
235+
const deniedInputs =
236+
tool.type === TOOL_TYPE.ACTOR ? getDeniedDirectActorInputs(tool.actorFullName, toolArgs) : [];
237+
if (tool.type === TOOL_TYPE.ACTOR && deniedInputs.length) {
238+
return {
239+
message: `Invalid arguments for tool "${tool.name}": ${buildDeniedInputsNote(tool.actorFullName, deniedInputs)}`,
240+
toolStatus: TOOL_STATUS.SOFT_FAIL,
241+
callDiagnostics: {
242+
failure_category: FAILURE_CATEGORY.INVALID_INPUT,
243+
...buildActorFields(actorName, actorId),
244+
},
245+
resolvedToolName,
246+
decodedArgs,
247+
};
248+
}
228249
if (!tool.ajvValidate(toolArgs)) {
229250
const errors = tool.ajvValidate.errors || [];
230251
const ajvErrorDetails = extractAjvErrorDetails(errors);

‎src/tools/AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic.
1818
- Tool implementations are grouped by domain, each registered through `registry.ts`:
1919
- `actors/` — search, details, call, the list of the account's Actors (`get-actor-list`), delete
2020
(`delete_actor.ts`), the actor-tools factory, the direct actor-tool executor (`actor_executor.ts`),
21-
`actor_definition.ts` (fetches and prunes an Actor's definition, `getActorDefinition`), and
21+
`actor_definition.ts` (fetches and prunes an Actor's definition, `getActorDefinition`),
22+
`actor_tool_contract.ts` (direct-tool input restrictions and defaults, shared by schema generation,
23+
the call engine's denied-input check and execution; generic calls use the original contract), and
2224
`actor_run_response.ts` —
2325
the one canonical run shape `call-actor` and `get-actor-run` share across sync, task
2426
and wait-timeout modes: storage IDs plus a `summary` (past) / `nextStep` (one primary

‎src/tools/actors/actor_executor.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { redactSkyfirePayId } from '../../utils/logging.js';
88
import { buildInvalidInputTexts, respondUserError } from '../../utils/mcp.js';
99
import { buildGetActorRunResponse } from '../runs/get_actor_run.js';
1010
import { abortRunOnSignal, CALL_ACTOR_WAIT_SECS_DEFAULT, fetchActorRunData } from './actor_run_response.js';
11+
import { buildDirectActorRunInput } from './actor_tool_contract.js';
1112

1213
/**
1314
* Direct actor tool executor. Mode-agnostic — used in both default and apps modes.
@@ -43,7 +44,9 @@ export const actorExecutor: ActorExecutor = {
4344

4445
let actorRun;
4546
try {
46-
actorRun = await apifyClient.actor(actorFullName).start(actorInput, params.callOptions);
47+
actorRun = await apifyClient
48+
.actor(actorFullName)
49+
.start(buildDirectActorRunInput(actorFullName, actorInput), params.callOptions);
4750
} catch (error) {
4851
// Platform can reject input our own AJV gate passed (see isActorInputValidationError).
4952
if (!isActorInputValidationError(error)) throw error;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ActorInputSchema } from '../../types.js';
2+
3+
type DirectActorPolicyConfig = {
4+
defaults?: Readonly<Record<string, unknown>>;
5+
deniedInputs?: readonly string[];
6+
/** Why, as served to the agent in the description and the refusal. */
7+
deniedInputsReason?: string;
8+
};
9+
10+
/** MCP-only contracts; the Actor's original API contract stays unchanged (#1574, #1575). */
11+
const DIRECT_ACTOR_POLICIES: Readonly<Record<string, DirectActorPolicyConfig>> = {
12+
'apify/website-content-crawler': {
13+
defaults: { respectRobotsTxtFile: true },
14+
deniedInputs: ['pageFunction', 'initialCookies'],
15+
deniedInputsReason:
16+
'page code can act on the crawled sites and session cookies can give access to accounts, which a read-only tool must not allow',
17+
},
18+
};
19+
20+
export function getDirectActorPolicy(actorFullName: string): DirectActorPolicyConfig | undefined {
21+
return DIRECT_ACTOR_POLICIES[actorFullName];
22+
}
23+
24+
export function buildDirectActorContract(actorFullName: string, input: ActorInputSchema | undefined) {
25+
const policy = getDirectActorPolicy(actorFullName);
26+
if (!policy || !input) return { input, description: '' };
27+
28+
const effectiveInput = structuredClone(input);
29+
const deniedInputs = policy.deniedInputs ?? [];
30+
for (const name of deniedInputs) delete effectiveInput.properties[name];
31+
effectiveInput.required = effectiveInput.required?.filter((name) => !deniedInputs.includes(name));
32+
for (const [name, value] of Object.entries(policy.defaults ?? {})) {
33+
if (effectiveInput.properties[name]) effectiveInput.properties[name].default = value;
34+
}
35+
36+
return {
37+
input: effectiveInput,
38+
description: deniedInputs.length ? buildDeniedInputsNote(actorFullName, deniedInputs) : '',
39+
};
40+
}
41+
42+
/** One sentence for the description and the refusal: what is off, that the Actor allows it, why, and the consequence. */
43+
export function buildDeniedInputsNote(actorFullName: string, names: readonly string[]): string {
44+
const reason = getDirectActorPolicy(actorFullName)?.deniedInputsReason;
45+
const list = names.map((name) => `"${name}"`).join(' and ');
46+
const [verb, pronoun] = names.length > 1 ? ['are', 'them'] : ['is', 'it'];
47+
return `${list} ${verb} not accepted by this tool, although the Actor supports ${pronoun}${reason ? `: ${reason}` : ''}, so this tool rejects ${pronoun}.`;
48+
}
49+
50+
/**
51+
* Denied inputs are absent from the served schema, but AJV keeps undeclared fields unless the Actor's
52+
* schema sets `additionalProperties: false`, in which case it strips them. Either way the run would
53+
* proceed, so the call is checked before validation.
54+
*/
55+
export function getDeniedDirectActorInputs(actorFullName: string, args: Record<string, unknown>): string[] {
56+
return (getDirectActorPolicy(actorFullName)?.deniedInputs ?? []).filter((name) => name in args);
57+
}
58+
59+
/** AJV does not fill defaults, so the direct tool must send its overrides explicitly. */
60+
export function buildDirectActorRunInput(
61+
actorFullName: string,
62+
input: Record<string, unknown>,
63+
): Record<string, unknown> {
64+
return { ...getDirectActorPolicy(actorFullName)?.defaults, ...input };
65+
}

‎src/tools/actors/actor_tools_factory.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js
4141
import { actorNameToToolName, isActorBlockedUnderPaymentProvider, resolveActorToolMode } from '../actor_tool_naming.js';
4242
import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js';
4343
import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js';
44+
import { buildDirectActorContract } from './actor_tool_contract.js';
4445

4546
/**
4647
* MCP-only opt-in injected next to the Actor's own input fields. Same contract as `call-actor`'s
@@ -134,9 +135,9 @@ function isReadOnlyActor({ actor, definition }: ActorInfo): boolean {
134135
*/
135136
export async function getNormalActorsAsTools(
136137
actorsInfo: ActorInfo[],
137-
options?: { mcpSessionId?: string; actorStore?: ActorStore },
138+
options?: { mcpSessionId?: string; actorStore?: ActorStore; isDirectTool?: boolean },
138139
): Promise<ToolEntry[]> {
139-
const { mcpSessionId, actorStore } = options ?? {};
140+
const { mcpSessionId, actorStore, isDirectTool = true } = options ?? {};
140141
const tools: ToolEntry[] = [];
141142

142143
for (const actorInfo of actorsInfo) {
@@ -145,10 +146,13 @@ export async function getNormalActorsAsTools(
145146
if (!definition) continue;
146147

147148
const { actorFullName, description: actorDescription } = definition;
148-
const isReadOnly = isReadOnlyActor(actorInfo);
149+
const contract = isDirectTool
150+
? buildDirectActorContract(actorFullName, definition.input)
151+
: { input: definition.input, description: '' };
152+
const isReadOnly = isReadOnlyActor({ ...actorInfo, definition: { ...definition, input: contract.input } });
149153
const isRag = actorFullName === RAG_WEB_BROWSER;
150154
const isWebFetch = actorFullName === WEB_FETCH;
151-
const { inputSchema } = buildActorInputSchema(definition.actorFullName, definition.input, isRag);
155+
const { inputSchema } = buildActorInputSchema(definition.actorFullName, contract.input, isRag);
152156

153157
// Inject the MCP-only `waitSecs` opt-in before AJV compile so the LLM can cap the wait.
154158
const inputSchemaWithWaitSecs = inputSchema as { properties?: Record<string, unknown> };
@@ -167,6 +171,7 @@ ${hasTool(HELPER_TOOLS.ACTOR_CALL) ? `Use this tool instead of the "${HELPER_TOO
167171
} else if (isWebFetch) {
168172
description += `\n\n${WEB_FETCH_ADDITIONAL_DESC}`;
169173
}
174+
if (contract.description) description += `\n\n${contract.description}`;
170175
return description;
171176
};
172177

@@ -350,9 +355,10 @@ export async function getActorsAsTools(
350355
mcpSessionId?: string;
351356
actorStore?: ActorStore;
352357
paymentProvider?: PaymentProvider;
358+
isDirectTool?: boolean;
353359
},
354360
): Promise<ActorsAsToolsResult> {
355-
const { mcpSessionId, actorStore, paymentProvider } = options ?? {};
361+
const { mcpSessionId, actorStore, paymentProvider, isDirectTool = true } = options ?? {};
356362
log.debug('Fetching Actors as tools', {
357363
actorNames: actorIdsOrNames,
358364
mcpSessionId,
@@ -429,7 +435,7 @@ export async function getActorsAsTools(
429435
}
430436

431437
const [normalTools, mcpServerTools] = await Promise.all([
432-
getNormalActorsAsTools(normalActorsInfo, { mcpSessionId, actorStore }),
438+
getNormalActorsAsTools(normalActorsInfo, { mcpSessionId, actorStore, isDirectTool }),
433439
getMCPServersAsTools(actorMCPServersInfo, apifyClient.token, mcpSessionId),
434440
]);
435441

‎src/tools/actors/call_actor.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ export async function resolveAndValidateActor(params: {
477477

478478
const { tools, errors } = await getActorsAsTools([actorName], apifyClient, {
479479
mcpSessionId: toolArgs.mcpSessionId,
480+
isDirectTool: false,
480481
});
481482

482483
// NOT_FOUND falls through to the structured "Actor not found" response below.

‎tests/unit/tools.actor_executor.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ describe('actorExecutor', () => {
148148
});
149149
});
150150

151+
describe('direct Actor defaults', () => {
152+
it.each([
153+
[{}, true],
154+
[{ respectRobotsTxtFile: false }, false],
155+
[{ respectRobotsTxtFile: true }, true],
156+
])('prepares WCC input %j with robots.txt %s', async (input, expected) => {
157+
const { params, spies } = buildParams(
158+
{ url: 'https://example.com', ...input },
159+
{ actorFullName: 'apify/website-content-crawler' },
160+
);
161+
await actorExecutor.executeActorTool(params);
162+
expect(spies.startInput).toEqual({ url: 'https://example.com', respectRobotsTxtFile: expected });
163+
});
164+
});
165+
151166
describe('platform input-validation error', () => {
152167
function invalidInputError(message: string): ApifyApiError {
153168
return new ApifyApiError(

‎tests/unit/tools.actor_tools_factory.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,60 @@ describe('getNormalActorsAsTools()', () => {
193193
});
194194
});
195195

196+
describe('direct Actor contract', () => {
197+
const actorInfo = createMockActorInfo('apify/website-content-crawler', {
198+
...LIMITED,
199+
inputProperties: {
200+
respectRobotsTxtFile: { type: 'boolean', default: false },
201+
pageFunction: { type: 'string', editor: 'hidden', default: '' },
202+
initialCookies: { type: 'array', editor: 'json', isSecret: true },
203+
},
204+
});
205+
206+
it('serves restricted inputs and derives read-only annotations without changing the source', async () => {
207+
const original = structuredClone(actorInfo);
208+
const [tool] = await getNormalActorsAsTools([actorInfo]);
209+
expect(tool.inputSchema.properties).not.toHaveProperty('pageFunction');
210+
expect(tool.inputSchema.properties).not.toHaveProperty('initialCookies');
211+
expect(tool.inputSchema.properties?.respectRobotsTxtFile).toMatchObject({ default: true });
212+
expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
213+
expect(tool.description).toContain('pageFunction');
214+
expect(tool.description).toContain('initialCookies');
215+
expect(actorInfo).toEqual(original);
216+
});
217+
218+
it('serves a plain object schema without composition keywords', async () => {
219+
// OpenAI rejects a function schema with allOf/anyOf/oneOf/not at the top level, strict or not.
220+
const [tool] = await getNormalActorsAsTools([actorInfo]);
221+
for (const keyword of ['allOf', 'anyOf', 'oneOf', 'not'])
222+
expect(tool.inputSchema).not.toHaveProperty(keyword);
223+
expect(tool.ajvValidate({ url: 'https://example.com', respectRobotsTxtFile: false })).toBe(true);
224+
});
225+
226+
it('removes denied fields from required', async () => {
227+
const info = structuredClone(actorInfo);
228+
info.definition.input!.required = ['url', 'initialCookies'];
229+
const [tool] = await getNormalActorsAsTools([info]);
230+
expect(tool.inputSchema.required).toEqual(['url']);
231+
expect(tool.ajvValidate({ url: 'https://example.com' })).toBe(true);
232+
});
233+
234+
it('retains permission checks and other secret inputs', async () => {
235+
const fullPermissions = structuredClone(actorInfo);
236+
fullPermissions.actor.actorPermissionLevel = 'FULL_PERMISSIONS';
237+
const otherSecret = structuredClone(actorInfo);
238+
otherSecret.definition.input!.properties.password = {
239+
type: 'string',
240+
title: 'Password',
241+
description: 'Password',
242+
isSecret: true,
243+
};
244+
for (const info of [fullPermissions, otherSecret]) {
245+
expect(await getAnnotations(info)).toMatchObject({ readOnlyHint: false, destructiveHint: true });
246+
}
247+
});
248+
});
249+
196250
// Regression: #1532 — the RAG description named apify/web-fetch when the session did not serve it.
197251
it('names apify/web-fetch in the RAG description only when its tool is served', async () => {
198252
const [tool] = await getNormalActorsAsTools([createMockActorInfo(RAG_WEB_BROWSER)]);

0 commit comments

Comments
 (0)