From dec2ccddd88a63e4d6b3f31b1517e0d5295e25e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 12:41:38 +0200 Subject: [PATCH 001/103] feat: Add Apify API search, operation and read tools Adds an api category, not enabled by default, with three tools modeled on the Stripe MCP server's API tools: - search-apify-api finds operations in the published OpenAPI spec by keywords. - fetch-apify-api-operation returns one operation's parameters and request body schema. - read-apify-api calls one GET operation by its operation ID. The server loads https://docs.apify.com/api/openapi.json and caches the operation index for an hour. The agent passes an operation ID and its parameters, never a URL: the server fills in the path, encodes each value, and accepts only the query parameters the operation declares. Deletions, synchronous runs, spending limits, and run charging are marked unavailable with the reason. Synchronous runs are unavailable even as GET, since they start a paid run and outlast the tool-call timeout. A request goes out once, without retries, and a body over 256 KB is not returned. --- README.md | 4 + src/const.ts | 3 + src/resources/api_resources.ts | 4 +- src/state.ts | 3 + src/tools/AGENTS.md | 4 + src/tools/api/apify_api_request.ts | 173 +++++++++++ src/tools/api/apify_api_spec.ts | 306 +++++++++++++++++++ src/tools/api/fetch_apify_api_operation.ts | 76 +++++ src/tools/api/read_apify_api.ts | 76 +++++ src/tools/api/search_apify_api.ts | 86 ++++++ src/tools/registry.ts | 4 + src/tools/structured_output_schemas.ts | 97 ++++++ tests/unit/helpers/apify_api_spec_fixture.ts | 186 +++++++++++ tests/unit/tools.apify_api.test.ts | 270 ++++++++++++++++ tests/unit/tools.apify_api_spec.test.ts | 145 +++++++++ tests/unit/tools.categories.test.ts | 10 +- tests/unit/tools.mode_contract.test.ts | 7 + 17 files changed, 1451 insertions(+), 3 deletions(-) create mode 100644 src/tools/api/apify_api_request.ts create mode 100644 src/tools/api/apify_api_spec.ts create mode 100644 src/tools/api/fetch_apify_api_operation.ts create mode 100644 src/tools/api/read_apify_api.ts create mode 100644 src/tools/api/search_apify_api.ts create mode 100644 tests/unit/helpers/apify_api_spec_fixture.ts create mode 100644 tests/unit/tools.apify_api.test.ts create mode 100644 tests/unit/tools.apify_api_spec.test.ts diff --git a/README.md b/README.md index d3afee188..b97776e21 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them: - **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages. - **Schedules**: Create, inspect, update, and delete schedules that run your Actors and tasks automatically. - **Builds**: Build an Actor version, check the status of a build, and retrieve its build log. +- **Apify API**: Search the Apify API reference, inspect an operation, and call read-only operations. ### Overview of available tools @@ -293,6 +294,9 @@ Legend for the **Enabled by default** column: | `get-actor-build` | builds | Get an Actor build's status. | | | `get-actor-build-log` | builds | Retrieve the logs for a specific Actor build. | | | `build-actor` | builds | Build an Actor version and wait a bounded time for the build to finish. | | +| `search-apify-api` | api | Search the Apify API reference for operations by keywords. | | +| `fetch-apify-api-operation` | api | Get an Apify API operation's parameters and request body schema. | | +| `read-apify-api` | api | Call a read-only (GET) Apify API operation by its operation ID. | | > **Note:** > diff --git a/src/const.ts b/src/const.ts index cd75fa74e..fe72c7206 100644 --- a/src/const.ts +++ b/src/const.ts @@ -73,6 +73,9 @@ export const HELPER_TOOLS = { STORE_SEARCH_WIDGET: 'search-actors-widget', DOCS_SEARCH: 'search-apify-docs', DOCS_FETCH: 'fetch-apify-docs', + API_SEARCH: 'search-apify-api', + API_OPERATION_FETCH: 'fetch-apify-api-operation', + API_READ: 'read-apify-api', PROBLEM_REPORT: 'report-problem', } as const; export type HelperToolName = (typeof HELPER_TOOLS)[keyof typeof HELPER_TOOLS]; diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index 1c36468ef..9550f63d9 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -137,8 +137,8 @@ async function buildLinkOutResult(uri: string, apifyClient: ApifyClient): Promis ); } -/** The mid-consumption abort axios raises when a streamed body crosses `maxContentLength`. */ -function isMaxContentLengthAbort(err: unknown): boolean { +/** The mid-consumption abort axios raises when a body crosses `maxContentLength`. */ +export function isMaxContentLengthAbort(err: unknown): boolean { return isAxiosError(err) && err.code === 'ERR_BAD_RESPONSE' && err.message.includes('maxContentLength'); } diff --git a/src/state.ts b/src/state.ts index 17ab8bd99..0c441bf9d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,3 +1,4 @@ +import type { ApiOperation } from './tools/api/apify_api_spec.js'; import type { ActorDefinitionWithInfo, ApifyDocsSearchResult } from './types.js'; import { TTLLRUCache } from './utils/ttl_lru.js'; @@ -16,3 +17,5 @@ export const searchApifyDocsCache = new TTLLRUCache( ); /** Stores processed Markdown content */ export const fetchApifyDocsCache = new TTLLRUCache(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS); +/** The Apify API operation index built from the published OpenAPI spec; one entry, keyed by the spec URL. */ +export const apifyApiOperationsCache = new TTLLRUCache>(1, APIFY_DOCS_CACHE_TTL_SECS); diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 321e523f1..a662afaa0 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -32,6 +32,10 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. `build-actor` (start a build of one version and wait for it); `build_helpers.ts` holds the allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. + - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get one + operation, and call a GET operation. `apify_api_spec.ts` builds the operation index from the spec + (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason. + `apify_api_request.ts` fills in the path, checks the query, and sends one request with no retries. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts new file mode 100644 index 000000000..7169b65aa --- /dev/null +++ b/src/tools/api/apify_api_request.ts @@ -0,0 +1,173 @@ +import { ApifyApiError } from 'apify-client'; +import type { AxiosResponse } from 'axios'; +import { z } from 'zod'; + +import type { ApifyClient } from '../../apify_client.js'; +import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; +import { isMaxContentLengthAbort } from '../../resources/api_resources.js'; +import type { ToolResponse } from '../../utils/mcp.js'; +import { respondOk, respondUserError } from '../../utils/mcp.js'; +import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; +import type { ApiAccess, ApiOperation } from './apify_api_spec.js'; +import { API_ACCESS } from './apify_api_spec.js'; + +/** Input fields the read and write tools share. */ +export const apiCallArgsShape = { + operationId: z + .string() + .min(1) + .describe('The operation ID, for example actor_get. Operation IDs are case-sensitive.'), + pathParams: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Values for the placeholders in the operation path, by name, for example {"actorId": "apify~web-scraper"}. ' + + 'An Actor, task or storage is given by its ID or as username~name.', + ), + query: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe( + 'Query parameters, by name, for example {"limit": 10}. Only parameters the operation declares are accepted.', + ), +}; + +export type ApiCallParams = { + query?: Record; + /** Aborts the request when the client cancels the tool call. */ + signal?: AbortSignal; +}; + +/** The not-found text, naming the search tool only when the session has it. */ +export function formatOperationNotFoundMessage(operationId: string, loadedToolNames: readonly string[]): string { + const findIt = loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) ? ` Find it with ${HELPER_TOOLS.API_SEARCH}.` : ''; + return `API operation ${operationId} not found.${findIt}`; +} + +/** The operation a tool with the given access may call, or the reason it may not. */ +export function resolveOperationToCall( + index: Map, + operationId: string, + access: Exclude, + loadedToolNames: readonly string[], +): { operation: ApiOperation } | { error: string } { + const operation = index.get(operationId); + if (!operation) return { error: formatOperationNotFoundMessage(operationId, loadedToolNames) }; + if (operation.access === API_ACCESS.UNAVAILABLE) { + return { error: `The API tools do not call ${operationId}. ${operation.unavailableReason}` }; + } + if (operation.access !== access) { + return { + error: `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ${access} access.`, + }; + } + return { operation }; +} + +const PATH_PARAMETER_REGEX = /\{([^}]+)\}/g; + +/** Fills the operation's path template; returns the reason instead when the parameters do not fit it. */ +export function buildRequestPath( + operation: ApiOperation, + pathParams: Record = {}, +): { path: string } | { error: string } { + const names = [...operation.path.matchAll(PATH_PARAMETER_REGEX)].map((match) => match[1]); + const unknownNames = Object.keys(pathParams).filter((name) => !names.includes(name)); + if (unknownNames.length > 0) { + return { + error: `${operation.operationId} has no path parameter ${unknownNames.join(', ')}. Its path is ${operation.path}.`, + }; + } + let { path } = operation; + for (const name of names) { + const value = pathParams[name]; + if (!value) return { error: `Missing path parameter ${name}. The path is ${operation.path}.` }; + // Encoded, `.` and `..` stay as they are and would move the request to another route. + if (value === '.' || value === '..') return { error: `Path parameter ${name} cannot be "${value}".` }; + // The API takes username~name; apify-client swaps the first slash the same way. + path = path.replace(`{${name}}`, encodeURIComponent(value.replace('/', '~'))); + } + return { path }; +} + +/** Checks the query against the parameters the operation declares; returns the reason on failure. */ +export function validateQueryParams( + operation: ApiOperation, + query: Record = {}, +): string | undefined { + const declared = operation.parameters.filter((parameter) => parameter.in === 'query'); + const declaredNames = new Set(declared.map((parameter) => parameter.name)); + const unknownNames = Object.keys(query).filter((name) => !declaredNames.has(name)); + if (unknownNames.length > 0) { + return ( + `${operation.operationId} does not take the query parameter ${unknownNames.join(', ')}. ` + + `It takes: ${[...declaredNames].join(', ') || 'none'}.` + ); + } + const missingNames = declared + .filter((parameter) => parameter.isRequired && query[parameter.name] === undefined) + .map((parameter) => parameter.name); + if (missingNames.length > 0) return `Missing required query parameter ${missingNames.join(', ')}.`; + // Same cap as the run and build tools: MCP clients stop waiting for a tool call after 60 seconds. + if (query.waitForFinish !== undefined && Number(query.waitForFinish) > WAIT_SECS_MAX) { + return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. Call the operation again to keep waiting.`; + } + return undefined; +} + +function formatOversizeMessage(operation: ApiOperation, path: string): string { + return ( + `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ` + + 'Narrow the request, for example with the limit, offset, or fields query parameters if the operation declares them.' + ); +} + +/** + * Sends one request to a filled-in operation path and returns the response body. + * + * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one + * attempt and no retries, since a retried write could apply twice, and apify-client would retry the + * `maxContentLength` abort as a network error. The instance still adds the token and the request-origin + * and payment headers, and parses JSON and text bodies. A non-2xx response is thrown as the + * `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. + */ +export async function callApiOperation( + client: ApifyClient, + operation: ApiOperation, + path: string, + params: ApiCallParams, +): Promise { + let response: AxiosResponse; + try { + response = await client.httpClient.axios.request({ + // `client.baseUrl` already ends with /v2, the prefix of every indexed path. + url: `${client.baseUrl}${path.slice('/v2'.length)}`, + method: operation.method, + params: params.query, + maxContentLength: MAX_INLINE_BYTES, + signal: params.signal, + }); + } catch (error) { + if (isMaxContentLengthAbort(error)) return respondUserError(formatOversizeMessage(operation, path)); + throw error; + } + if (response.status >= 300) throw new ApifyApiError(response, 1); + + const contentTypeHeader = response.headers['content-type']; + const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined; + // apify-client parses JSON and text bodies; a binary body stays a Buffer and is not returned. + const isBinary = Buffer.isBuffer(response.data); + const structuredContent = { + operationId: operation.operationId, + method: operation.method, + path, + statusCode: response.status, + ...(contentType && { contentType }), + data: isBinary || response.data === undefined ? null : response.data, + }; + const summary = isBinary + ? `${operation.method} ${path} returned HTTP ${response.status} with a binary body ` + + `(${contentType ?? 'no Content-Type'}, ${(response.data as Buffer).length} bytes), which is not shown.` + : `${operation.method} ${path} returned HTTP ${response.status}.`; + return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); +} diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts new file mode 100644 index 000000000..31c266304 --- /dev/null +++ b/src/tools/api/apify_api_spec.ts @@ -0,0 +1,306 @@ +import { z } from 'zod'; + +import { apifyApiOperationsCache } from '../../state.js'; + +/** The published Apify API spec: the only list of operations the API tools call. */ +export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; + +export const API_ACCESS = { + READ: 'read', + WRITE: 'write', + UNAVAILABLE: 'unavailable', +} as const; +export type ApiAccess = (typeof API_ACCESS)[keyof typeof API_ACCESS]; + +/** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ +const INDEXED_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; +type ApiMethod = (typeof INDEXED_METHODS)[number]; + +export type ApiParameter = { + name: string; + in: 'path' | 'query'; + isRequired: boolean; + description?: string; + schema?: unknown; +}; + +export type ApiOperation = { + operationId: string; + method: ApiMethod; + /** Path template, for example `/v2/actors/{actorId}`. */ + path: string; + summary: string; + description: string; + tags: string[]; + /** Path and query parameters; header parameters are left out, the tools never send them. */ + parameters: ApiParameter[]; + /** Dereferenced JSON schema of the request body; absent when the operation takes none. */ + requestBody?: { isRequired: boolean; schema: unknown }; + /** Top-level body fields the write tool refuses for this operation. */ + refusedBodyFields: string[]; + access: ApiAccess; + /** Why the API tools do not call the operation; set exactly when `access` is unavailable. */ + unavailableReason?: string; +}; + +const DELETE_REASON = + 'Deletion cannot be undone, so the API tools do not delete. The user can delete it in Apify Console.'; +const SYNC_RUN_REASON = + 'It waits up to 300 seconds for the run to finish, longer than MCP clients wait for a tool call. ' + + 'Start the run with the asynchronous run operation instead.'; + +/** Synchronous run paths. Some are GET, but every one of them starts a paid run. */ +const SYNC_RUN_PATH_REGEX = /\/run-sync(-get-dataset-items)?$/; + +/** Operations the API tools refuse by ID, with the reason they report. */ +const UNAVAILABLE_OPERATION_REASONS: ReadonlyMap = new Map([ + ['users_me_limits_put', "It changes the account's spending limits. The user can change them in Apify Console."], + ['PostChargeRun', 'It charges the user of a pay-per-event run. Only the Actor itself charges for its events.'], +]); + +/** + * Body fields that publish an Actor or task, change its pricing or permissions, or change who can read + * a storage. They are refused only where the operation's schema declares them: a free-form body, such + * as a key-value store record, can carry the same names as plain data. + */ +export const REFUSED_BODY_FIELDS: ReadonlySet = new Set([ + 'isPublic', + 'pricingInfos', + 'actorPermissionLevel', + 'generalAccess', +]); + +/** Schema keywords that only cost context: examples and the docs' vendor extensions (`x-*`). */ +const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); + +const openApiOperationValidator = z.object({ + operationId: z.string().min(1), + summary: z.string().optional(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + deprecated: z.boolean().optional(), + parameters: z.array(z.unknown()).optional(), + requestBody: z.unknown().optional(), +}); + +const openApiParameterValidator = z.object({ + name: z.string().min(1), + in: z.string(), + required: z.boolean().optional(), + description: z.string().optional(), + schema: z.unknown().optional(), +}); + +const openApiRequestBodyValidator = z.object({ + required: z.boolean().optional(), + content: z.record(z.string(), z.object({ schema: z.unknown().optional() })), +}); + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Walks a local `#/...` reference; `undefined` when it does not resolve. */ +function resolveLocalRef(spec: unknown, ref: string): unknown { + if (!ref.startsWith('#/')) return undefined; + let node: unknown = spec; + for (const segment of ref.slice(2).split('/')) { + if (!isRecord(node)) return undefined; + node = node[segment]; + } + return node; +} + +/** + * Resolves local `$ref`s and drops examples and vendor extensions. Property names under `properties` + * are kept as they are, since a property can be named `example`. + */ +function dereference(node: unknown, spec: unknown, seenRefs: ReadonlySet = new Set()): unknown { + if (Array.isArray(node)) return node.map((item) => dereference(item, spec, seenRefs)); + if (!isRecord(node)) return node; + if (typeof node.$ref === 'string') { + // A reference cycle would recurse forever; the published spec has none. + if (seenRefs.has(node.$ref)) return {}; + return dereference(resolveLocalRef(spec, node.$ref), spec, new Set([...seenRefs, node.$ref])); + } + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (DROPPED_SCHEMA_KEYS.has(key) || key.startsWith('x-')) continue; + result[key] = + key === 'properties' && isRecord(value) + ? Object.fromEntries( + Object.entries(value).map(([name, schema]) => [name, dereference(schema, spec, seenRefs)]), + ) + : dereference(value, spec, seenRefs); + } + return result; +} + +/** Property names a dereferenced schema declares at its top level, including through `allOf`/`anyOf`/`oneOf`. */ +function extractTopLevelPropertyNames(schema: unknown): Set { + if (!isRecord(schema)) return new Set(); + const names = new Set(isRecord(schema.properties) ? Object.keys(schema.properties) : []); + for (const key of ['allOf', 'anyOf', 'oneOf']) { + const parts = schema[key]; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + for (const name of extractTopLevelPropertyNames(part)) names.add(name); + } + } + return names; +} + +function resolveAccess( + method: ApiMethod, + path: string, + operationId: string, +): Pick { + if (method === 'DELETE') return { access: API_ACCESS.UNAVAILABLE, unavailableReason: DELETE_REASON }; + if (SYNC_RUN_PATH_REGEX.test(path)) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: SYNC_RUN_REASON }; + const reason = UNAVAILABLE_OPERATION_REASONS.get(operationId); + if (reason) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: reason }; + return { access: method === 'GET' ? API_ACCESS.READ : API_ACCESS.WRITE }; +} + +function parseParameters(rawParameters: unknown[] | undefined, spec: unknown): ApiParameter[] { + const parameters: ApiParameter[] = []; + for (const rawParameter of rawParameters ?? []) { + const parsed = openApiParameterValidator.safeParse(dereference(rawParameter, spec)); + if (!parsed.success) continue; + const { name, in: location, required, description, schema } = parsed.data; + if (location !== 'path' && location !== 'query') continue; + parameters.push({ + name, + in: location, + // OpenAPI makes every path parameter required. + isRequired: location === 'path' || required === true, + ...(description && { description }), + ...(schema !== undefined && { schema }), + }); + } + return parameters; +} + +function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation['requestBody'] { + if (rawRequestBody === undefined) return undefined; + const parsed = openApiRequestBodyValidator.safeParse(dereference(rawRequestBody, spec)); + if (!parsed.success) return undefined; + const { content, required } = parsed.data; + // Every body is sent as JSON; the key-value store record operations declare `*/*`. + const media = content['application/json'] ?? content['*/*']; + if (!media) return undefined; + return { isRequired: required === true, schema: media.schema ?? {} }; +} + +/** + * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations and + * anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. + */ +export function buildApiOperationIndex(spec: unknown): Map { + const index = new Map(); + const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; + for (const [path, pathItem] of Object.entries(paths)) { + if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; + for (const method of INDEXED_METHODS) { + const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); + if (!parsed.success || parsed.data.deprecated) continue; + const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; + const body = parseRequestBody(requestBody, spec); + const declaredBodyFields = extractTopLevelPropertyNames(body?.schema); + index.set(operationId, { + operationId, + method, + path, + summary: summary ?? '', + description: description ?? '', + tags: tags ?? [], + parameters: parseParameters(parameters, spec), + ...(body && { requestBody: body }), + refusedBodyFields: [...REFUSED_BODY_FIELDS].filter((field) => declaredBodyFields.has(field)), + ...resolveAccess(method, path, operationId), + }); + } + } + return index; +} + +let pendingIndex: Promise> | undefined; + +/** + * The operation index, from the published spec, cached for an hour. Concurrent calls share one + * download. A failed download throws: the tools never call an operation the spec does not list. + */ +export async function fetchApiOperationIndex(): Promise> { + const cached = apifyApiOperationsCache.get(APIFY_API_OPENAPI_URL); + if (cached) return cached; + pendingIndex ??= (async () => { + const response = await fetch(APIFY_API_OPENAPI_URL); + if (!response.ok) { + throw new Error( + `Failed to load the Apify API operations from ${APIFY_API_OPENAPI_URL}: HTTP ${response.status}.`, + ); + } + const index = buildApiOperationIndex(await response.json()); + apifyApiOperationsCache.set(APIFY_API_OPENAPI_URL, index); + return index; + })().finally(() => { + pendingIndex = undefined; + }); + return pendingIndex; +} + +const SEARCH_STOP_WORDS: ReadonlySet = new Set([ + 'a', + 'all', + 'an', + 'and', + 'for', + 'in', + 'my', + 'of', + 'on', + 'or', + 'the', + 'to', + 'with', +]); + +/** Lowercase words of a text, splitting camelCase, snake_case, kebab-case and paths. */ +function splitWords(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** A prefix match either way, so `runs` finds `run` and `env` finds `environment`. */ +function hasMatchingWord(term: string, words: string[]): boolean { + return words.some((word) => word.startsWith(term) || (word.length >= 3 && term.startsWith(word))); +} + +/** + * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the + * operation ID or path, else 1 in the tags. Ties go to the shorter path, so `/v2/datasets/{datasetId}` + * comes before the same operation on a run's default dataset. + */ +export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { + const terms = [...new Set(splitWords(query))].filter((term) => !SEARCH_STOP_WORDS.has(term)); + const scored: { operation: ApiOperation; score: number }[] = []; + for (const operation of index.values()) { + const summaryWords = splitWords(operation.summary); + const idWords = splitWords(`${operation.operationId} ${operation.path}`); + const tagWords = splitWords(operation.tags.join(' ')); + let score = 0; + for (const term of terms) { + if (hasMatchingWord(term, summaryWords)) score += 3; + else if (hasMatchingWord(term, idWords)) score += 2; + else if (hasMatchingWord(term, tagWords)) score += 1; + } + if (score > 0) scored.push({ operation, score }); + } + return scored + .sort((a, b) => b.score - a.score || a.operation.path.length - b.operation.path.length) + .slice(0, limit) + .map(({ operation }) => operation); +} diff --git a/src/tools/api/fetch_apify_api_operation.ts b/src/tools/api/fetch_apify_api_operation.ts new file mode 100644 index 000000000..114d7775a --- /dev/null +++ b/src/tools/api/fetch_apify_api_operation.ts @@ -0,0 +1,76 @@ +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS } from '../../const.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { respondOk, respondUserError } from '../../utils/mcp.js'; +import { apifyApiOperationOutputSchema } from '../structured_output_schemas.js'; +import { apiCallArgsShape, formatOperationNotFoundMessage } from './apify_api_request.js'; +import { fetchApiOperationIndex } from './apify_api_spec.js'; + +const fetchApifyApiOperationArgs = z.object({ + operationId: apiCallArgsShape.operationId, +}); + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const findOperation = hasTool(HELPER_TOOLS.API_SEARCH) + ? ` Find the operation ID with ${HELPER_TOOLS.API_SEARCH}.` + : ''; + return dedent` + Get one Apify API operation: its method, path, description, path and query parameters, and the + JSON schema of its request body. Also returns its access (read, write, or unavailable with the + reason) and the body fields the API tools refuse to set.${findOperation} + + USAGE: + - Use before calling an operation, to pass the right parameters and body. + + USAGE EXAMPLES: + - user_input: What parameters does the dataset items operation take? + - user_input: How do I update a webhook through the API? + `; +} + +/** + * Returns one operation of the published Apify API spec, https://docs.apify.com/api/v2. + */ +export const fetchApifyApiOperation: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.API_OPERATION_FETCH, + title: 'Fetch Apify API operation', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + inputSchema: z.toJSONSchema(fetchApifyApiOperationArgs) as ToolInputSchema, + outputSchema: apifyApiOperationOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(fetchApifyApiOperationArgs)), + annotations: { + title: 'Fetch Apify API operation', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const parsed = fetchApifyApiOperationArgs.parse(toolArgs.args); + const index = await fetchApiOperationIndex(); + const operation = index.get(parsed.operationId); + if (!operation) { + return respondUserError(formatOperationNotFoundMessage(parsed.operationId, toolArgs.loadedToolNames)); + } + const result = { + operationId: operation.operationId, + method: operation.method, + path: operation.path, + summary: operation.summary, + description: operation.description, + access: operation.access, + ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), + parameters: operation.parameters, + ...(operation.requestBody && { requestBody: operation.requestBody }), + refusedBodyFields: operation.refusedBodyFields, + }; + const summary = `${operation.operationId}: ${operation.method} ${operation.path}, ${operation.access} access.`; + return respondOk([JSON.stringify(result), summary], { structuredContent: result }); + }, +} as const); diff --git a/src/tools/api/read_apify_api.ts b/src/tools/api/read_apify_api.ts new file mode 100644 index 000000000..b2cd56bf1 --- /dev/null +++ b/src/tools/api/read_apify_api.ts @@ -0,0 +1,76 @@ +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { respondUserError } from '../../utils/mcp.js'; +import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; +import { + apiCallArgsShape, + buildRequestPath, + callApiOperation, + resolveOperationToCall, + validateQueryParams, +} from './apify_api_request.js'; +import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; + +const readApifyApiArgs = z.object(apiCallArgsShape); + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const getParameters = hasTool(HELPER_TOOLS.API_OPERATION_FETCH) + ? `\nGet the operation's parameters first with ${HELPER_TOOLS.API_OPERATION_FETCH}.` + : ''; + return dedent` + Call one Apify API operation with read access (GET), by its operation ID. The server builds the + URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} + Returns the response body as the API sends it, JSON with its data wrapper included; a body over + ${MAX_INLINE_BYTES} bytes is not returned. + + USAGE: + - Use for data no dedicated tool returns, such as webhooks, usage and limits. + + USAGE EXAMPLES: + - user_input: List the webhooks on my account + - user_input: How much of my monthly usage have I spent? + `; +} + +/** + * Calls one GET operation of the published Apify API spec, https://docs.apify.com/api/v2. + */ +export const readApifyApi: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.API_READ, + title: 'Read Apify API', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + inputSchema: z.toJSONSchema(readApifyApiArgs) as ToolInputSchema, + outputSchema: apifyApiCallOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(readApifyApiArgs)), + annotations: { + title: 'Read Apify API', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const parsed = readApifyApiArgs.parse(toolArgs.args); + const index = await fetchApiOperationIndex(); + const resolved = resolveOperationToCall(index, parsed.operationId, API_ACCESS.READ, toolArgs.loadedToolNames); + if ('error' in resolved) return respondUserError(resolved.error); + const { operation } = resolved; + + const request = buildRequestPath(operation, parsed.pathParams); + if ('error' in request) return respondUserError(request.error); + const queryError = validateQueryParams(operation, parsed.query); + if (queryError) return respondUserError(queryError); + + return callApiOperation(toolArgs.apifyClient, operation, request.path, { + query: parsed.query, + signal: toolArgs.signal, + }); + }, +} as const); diff --git a/src/tools/api/search_apify_api.ts b/src/tools/api/search_apify_api.ts new file mode 100644 index 000000000..c5ff15ce8 --- /dev/null +++ b/src/tools/api/search_apify_api.ts @@ -0,0 +1,86 @@ +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS } from '../../const.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js'; +import { respondOk } from '../../utils/mcp.js'; +import { apifyApiSearchOutputSchema } from '../structured_output_schemas.js'; +import { fetchApiOperationIndex, searchApiOperations } from './apify_api_spec.js'; + +const searchApifyApiArgs = z.object({ + query: z + .string() + .min(1) + .describe('Keywords for what the operation does, for example "list webhooks" or "update dataset".'), + limit: z + .number() + .int() + .min(1) + .max(20) + .describe('Maximum number of operations to return. Default is 10. Maximum is 20.') + .default(10), +}); + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); + const nextSteps = [ + hasTool(HELPER_TOOLS.API_OPERATION_FETCH) && `get its parameters with ${HELPER_TOOLS.API_OPERATION_FETCH}`, + callTools.length > 0 && `call it with ${callTools.join(' or ')}`, + ].filter(Boolean); + const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; + return dedent` + Search the Apify API reference for operations by keywords. Returns each operation's ID, method, + path, summary, and access: read, write, or unavailable with the reason the API tools do not call it.${nextStepsSentence} + Prefer a dedicated Apify tool when one does what the user asks. + + USAGE: + - Use when no dedicated tool does what the user asks and the Apify API might. + - Tell the user the reason when the operation they need is unavailable. + + USAGE EXAMPLES: + - user_input: List the webhooks on my account + - user_input: Rename my dataset to leads-2026 + - user_input: How much of my monthly usage have I spent? + `; +} + +/** + * Searches the operations of the published Apify API spec, https://docs.apify.com/api/v2. + */ +export const searchApifyApi: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.API_SEARCH, + title: 'Search Apify API', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + inputSchema: fixZodSchemaRequired(z.toJSONSchema(searchApifyApiArgs)) as ToolInputSchema, + outputSchema: apifyApiSearchOutputSchema, + ajvValidate: compileSchema(fixZodSchemaRequired(z.toJSONSchema(searchApifyApiArgs))), + annotations: { + title: 'Search Apify API', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + call: async (toolArgs: InternalToolArgs) => { + const parsed = searchApifyApiArgs.parse(toolArgs.args); + const index = await fetchApiOperationIndex(); + const operations = searchApiOperations(index, parsed.query, parsed.limit).map((operation) => ({ + operationId: operation.operationId, + method: operation.method, + path: operation.path, + summary: operation.summary, + access: operation.access, + ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), + })); + const result = { operations }; + const summary = + operations.length > 0 + ? `Found ${operations.length} API operations for "${parsed.query}".` + : `No API operation matches "${parsed.query}". Try other keywords.`; + return respondOk([JSON.stringify(result), summary], { structuredContent: result }); + }, +} as const); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 0251ed19a..41ce3a616 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -20,6 +20,9 @@ import { SERVER_MODE } from '../types.js'; import { callActor } from './actors/call_actor.js'; import { fetchActorDetails } from './actors/fetch_actor_details.js'; import { searchActors } from './actors/search_actors.js'; +import { fetchApifyApiOperation } from './api/fetch_apify_api_operation.js'; +import { readApifyApi } from './api/read_apify_api.js'; +import { searchApifyApi } from './api/search_apify_api.js'; import { buildActor } from './builds/build_actor.js'; import { getActorBuild } from './builds/get_actor_build.js'; import { getActorBuildLog } from './builds/get_actor_build_log.js'; @@ -70,6 +73,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, buildActor], + api: [searchApifyApi, fetchApifyApiOperation, readApifyApi], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 004d76337..033a4cf35 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -877,3 +877,100 @@ export const keyValueStoreRecordOutputSchema = { }, required: ['keyValueStoreId', 'key', 'value', 'summary'], }; + +const apiAccessProperty = { + type: 'string' as const, + enum: ['read', 'write', 'unavailable'], + description: 'read (GET) or write (POST, PUT) access, or unavailable when the API tools do not call it', +}; +const apiUnavailableReasonProperty = { + type: 'string' as const, + description: 'Why the API tools do not call the operation; only when access is unavailable', +}; + +/** + * Schema for the operations found by search-apify-api. + */ +export const apifyApiSearchOutputSchema = { + type: 'object' as const, + properties: { + operations: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + operationId: { type: 'string', description: 'Operation ID' }, + method: { type: 'string', description: 'HTTP method' }, + path: { type: 'string', description: 'Path template' }, + summary: { type: 'string', description: 'What the operation does' }, + access: apiAccessProperty, + unavailableReason: apiUnavailableReasonProperty, + }, + required: ['operationId', 'method', 'path', 'summary', 'access'], + }, + }, + }, + required: ['operations'], +}; + +/** + * Schema for one operation's details (fetch-apify-api-operation). + */ +export const apifyApiOperationOutputSchema = { + type: 'object' as const, + properties: { + operationId: { type: 'string', description: 'Operation ID' }, + method: { type: 'string', description: 'HTTP method' }, + path: { type: 'string', description: 'Path template; each {name} is a path parameter' }, + summary: { type: 'string', description: 'What the operation does' }, + description: { type: 'string', description: 'Full description from the API reference' }, + access: apiAccessProperty, + unavailableReason: apiUnavailableReasonProperty, + parameters: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Parameter name' }, + in: { type: 'string', enum: ['path', 'query'], description: 'Where the parameter goes' }, + isRequired: { type: 'boolean', description: 'Whether the parameter is required' }, + description: { type: 'string', description: 'What the parameter does' }, + schema: { description: 'JSON schema of the value' }, + }, + required: ['name', 'in', 'isRequired'], + }, + description: 'Path and query parameters', + }, + requestBody: { + type: 'object' as const, + properties: { + isRequired: { type: 'boolean', description: 'Whether the body is required' }, + schema: { description: 'JSON schema of the body, sent as JSON' }, + }, + required: ['isRequired', 'schema'], + description: 'Only when the operation takes a body', + }, + refusedBodyFields: { + type: 'array' as const, + items: { type: 'string' }, + description: 'Body fields the API tools refuse to set for this operation', + }, + }, + required: ['operationId', 'method', 'path', 'summary', 'description', 'access', 'parameters', 'refusedBodyFields'], +}; + +/** + * Schema for the response of an API call (read-apify-api). + */ +export const apifyApiCallOutputSchema = { + type: 'object' as const, + properties: { + operationId: { type: 'string', description: 'Operation ID' }, + method: { type: 'string', description: 'HTTP method' }, + path: { type: 'string', description: 'Request path, with the path parameters filled in' }, + statusCode: { type: 'integer', description: 'HTTP status code' }, + contentType: { type: 'string', description: 'Content-Type of the response' }, + data: { description: 'Response body: parsed JSON, text, or null for an empty or binary body' }, + }, + required: ['operationId', 'method', 'path', 'statusCode', 'data'], +}; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts new file mode 100644 index 000000000..4eb738b50 --- /dev/null +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -0,0 +1,186 @@ +/** + * A small OpenAPI document shaped like https://docs.apify.com/api/openapi.json: `$ref` parameters and + * bodies, examples and `x-*` extensions, and one operation of each kind the API tools treat differently. + */ +export const API_SPEC_FIXTURE = { + openapi: '3.1.2', + paths: { + '/v2/datasets/{datasetId}': { + get: { + operationId: 'dataset_get', + summary: 'Get dataset', + tags: ['Storage/Datasets'], + parameters: [{ $ref: '#/components/parameters/datasetId' }], + 'x-js-name': 'get', + }, + put: { + operationId: 'dataset_put', + summary: 'Update dataset', + tags: ['Storage/Datasets'], + parameters: [{ $ref: '#/components/parameters/datasetId' }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UpdateDatasetRequest' }, + examples: { common: { value: { name: 'leads' } } }, + }, + }, + }, + }, + delete: { + operationId: 'dataset_delete', + summary: 'Delete dataset', + tags: ['Storage/Datasets'], + parameters: [{ $ref: '#/components/parameters/datasetId' }], + }, + }, + '/v2/datasets/{datasetId}/items': { + get: { + operationId: 'dataset_items_get', + summary: 'Get dataset items', + tags: ['Storage/Datasets'], + parameters: [ + { $ref: '#/components/parameters/datasetId' }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'format', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'Accept-Encoding', in: 'header', schema: { type: 'string' } }, + ], + }, + head: { operationId: 'dataset_items_head', summary: 'Get dataset items headers' }, + }, + '/v2/actor-runs/{runId}/dataset': { + put: { + operationId: 'actorRun_dataset_put', + summary: 'Update default dataset', + tags: ['Default dataset'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateDatasetRequest' } } }, + }, + }, + }, + '/v2/actor-runs/{runId}': { + get: { + operationId: 'actorRun_get', + summary: 'Get run', + tags: ['Actor runs'], + parameters: [ + { name: 'runId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'waitForFinish', in: 'query', schema: { type: 'number' } }, + ], + }, + }, + '/v2/actor-runs/{runId}/abort': { + post: { + operationId: 'actorRun_abort_post', + summary: 'Abort run', + tags: ['Actor runs'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, + '/v2/actors/{actorId}': { + put: { + operationId: 'actor_put', + summary: 'Update Actor', + tags: ['Actors'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateActorRequest' } } }, + }, + }, + }, + '/v2/actors/{actorId}/runs/{runId}': { + get: { + operationId: 'actors_run_get', + summary: 'Get run', + deprecated: true, + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, + }, + '/v2/actors/{actorId}/run-sync': { + get: { + operationId: 'actor_runSync_get', + summary: 'Run Actor synchronously without input', + tags: ['Actors/Actor runs'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, + }, + '/v2/key-value-stores/{storeId}/records/{recordKey}': { + put: { + operationId: 'keyValueStore_record_put', + summary: 'Store record', + tags: ['Storage/Key-value stores'], + parameters: [ + { name: 'storeId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'recordKey', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { '*/*': { schema: { type: 'object', additionalProperties: true } } }, + }, + }, + }, + '/v2/users/me/limits': { + put: { + operationId: 'users_me_limits_put', + summary: 'Update limits', + tags: ['Users/Usage'], + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + }, + }, + '/v2/webhooks': { + get: { operationId: 'webhooks_get', summary: 'Get list of webhooks', tags: ['Webhooks/Webhooks'] }, + }, + '/v2/actors/{actorId}/webhooks': { + get: { + operationId: 'actor_webhooks_get', + summary: 'Get list of webhooks', + tags: ['Actors/Webhook collection'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, + }, + '/outside/v2': { get: { operationId: 'outside_get', summary: 'Outside the API' } }, + '/v2/broken': { get: { summary: 'No operation ID' } }, + }, + components: { + parameters: { + datasetId: { + name: 'datasetId', + in: 'path', + required: true, + description: 'Dataset ID or username~dataset-name.', + schema: { type: 'string', example: 'WkzbQMuFYuamGv3YF' }, + }, + actorId: { + name: 'actorId', + in: 'path', + required: true, + description: 'Actor ID or username~actor-name.', + schema: { type: 'string' }, + }, + }, + schemas: { + UpdateDatasetRequest: { + type: 'object', + properties: { + name: { type: 'string' }, + generalAccess: { type: 'string' }, + example: { type: 'string', description: 'A property that happens to be named example.' }, + }, + }, + UpdateActorRequest: { + allOf: [{ $ref: '#/components/schemas/ActorPermissionFields' }], + properties: { + title: { type: 'string' }, + isPublic: { type: 'boolean' }, + pricingInfos: { type: 'array' }, + }, + }, + ActorPermissionFields: { + type: 'object', + properties: { actorPermissionLevel: { type: 'string' } }, + }, + }, + }, +}; diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts new file mode 100644 index 000000000..e404cae16 --- /dev/null +++ b/tests/unit/tools.apify_api.test.ts @@ -0,0 +1,270 @@ +import { ApifyApiError } from 'apify-client'; +import { AxiosError } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; +import { buildRequestPath, validateQueryParams } from '../../src/tools/api/apify_api_request.js'; +import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; +import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; +import { fetchApifyApiOperation } from '../../src/tools/api/fetch_apify_api_operation.js'; +import { readApifyApi } from '../../src/tools/api/read_apify_api.js'; +import { searchApifyApi } from '../../src/tools/api/search_apify_api.js'; +import { + apifyApiCallOutputSchema, + apifyApiOperationOutputSchema, + apifyApiSearchOutputSchema, +} from '../../src/tools/structured_output_schemas.js'; +import type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { API_SPEC_FIXTURE } from './helpers/apify_api_spec_fixture.js'; +import { + expectSchemaConformingStructuredContent, + expectSoftFailInvalidInput, + stubToolCallContext, + type TextToolResult, + type ToolTelemetrySnapshot, +} from './helpers/tool_context.js'; + +vi.mock('../../src/tools/api/apify_api_spec.js', async (importOriginal) => { + const original = await importOriginal(); + const { API_SPEC_FIXTURE: spec } = await import('./helpers/apify_api_spec_fixture.js'); + return { ...original, fetchApiOperationIndex: vi.fn(async () => original.buildApiOperationIndex(spec)) }; +}); + +const INDEX = buildApiOperationIndex(API_SPEC_FIXTURE); +const BASE_URL = 'https://api.apify.com/v2'; + +const requestMock = vi.fn(); +const stubClient = { + baseUrl: BASE_URL, + httpClient: { axios: { request: requestMock } }, +} as unknown as InternalToolArgs['apifyClient']; + +function mockResponse(status: number, data: unknown, contentType = 'application/json; charset=utf-8') { + return { + status, + statusText: 'Status', + data, + headers: { 'content-type': contentType }, + config: { method: 'get', url: `${BASE_URL}/datasets/abc` }, + }; +} + +async function callTool(tool: unknown, args: Record, loadedToolNames?: string[]) { + const context = stubToolCallContext(args, stubClient); + if (loadedToolNames) context.loadedToolNames = loadedToolNames; + return (await (tool as HelperTool).call(context)) as TextToolResult & { toolTelemetry?: ToolTelemetrySnapshot }; +} + +beforeEach(() => { + requestMock.mockReset(); +}); + +describe('buildRequestPath()', () => { + const datasetItems = INDEX.get('dataset_items_get')!; + + it('fills and encodes the path parameters, turning username/name into username~name', () => { + expect(buildRequestPath(datasetItems, { datasetId: 'john/my data' })).toEqual({ + path: '/v2/datasets/john~my%20data/items', + }); + expect(buildRequestPath(INDEX.get('webhooks_get')!)).toEqual({ path: '/v2/webhooks' }); + }); + + it.each([ + [{}, 'Missing path parameter datasetId'], + [{ datasetId: '' }, 'Missing path parameter datasetId'], + [{ datasetId: 'abc', runId: 'x' }, 'has no path parameter runId'], + [{ datasetId: '..' }, 'cannot be ".."'], + [{ datasetId: '.' }, 'cannot be "."'], + ])('refuses %j', (pathParams, reason) => { + const result = buildRequestPath(datasetItems, pathParams); + expect('error' in result && result.error).toContain(reason); + }); +}); + +describe('validateQueryParams()', () => { + it('accepts declared parameters', () => { + expect(validateQueryParams(INDEX.get('dataset_items_get')!, { format: 'json', limit: 5 })).toBeUndefined(); + }); + + it('refuses a parameter the operation does not declare, such as a token', () => { + expect(validateQueryParams(INDEX.get('dataset_items_get')!, { format: 'json', token: 'secret' })).toBe( + 'dataset_items_get does not take the query parameter token. It takes: limit, format.', + ); + }); + + it('refuses a call without a required parameter', () => { + expect(validateQueryParams(INDEX.get('dataset_items_get')!, { limit: 5 })).toBe( + 'Missing required query parameter format.', + ); + }); + + it('caps waitForFinish below the tool-call timeout', () => { + expect(validateQueryParams(INDEX.get('actorRun_get')!, { waitForFinish: 45 })).toBeUndefined(); + expect(validateQueryParams(INDEX.get('actorRun_get')!, { waitForFinish: 60 })).toContain('at most 45 seconds'); + }); +}); + +describe('search-apify-api', () => { + it('returns the matching operations with their access', async () => { + const result = await callTool(searchApifyApi, { query: 'delete dataset', limit: 1 }); + + expectSchemaConformingStructuredContent(result, apifyApiSearchOutputSchema); + expect(result.structuredContent).toEqual({ + operations: [ + { + operationId: 'dataset_delete', + method: 'DELETE', + path: '/v2/datasets/{datasetId}', + summary: 'Delete dataset', + access: 'unavailable', + unavailableReason: expect.stringContaining('Deletion cannot be undone'), + }, + ], + }); + }); + + it('says so when nothing matches', async () => { + const result = await callTool(searchApifyApi, { query: 'zebra' }); + + expect(result.structuredContent).toEqual({ operations: [] }); + expect(result.content[1].text).toBe('No API operation matches "zebra". Try other keywords.'); + }); +}); + +describe('fetch-apify-api-operation', () => { + it('returns the parameters, body schema and refused fields', async () => { + const result = await callTool(fetchApifyApiOperation, { operationId: 'actor_put' }); + + expectSchemaConformingStructuredContent(result, apifyApiOperationOutputSchema); + expect(result.structuredContent).toMatchObject({ + operationId: 'actor_put', + method: 'PUT', + path: '/v2/actors/{actorId}', + access: 'write', + parameters: [{ name: 'actorId', in: 'path', isRequired: true }], + requestBody: { isRequired: false }, + refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel'], + }); + }); + + it('names the search tool on an unknown operation only when the session has it', async () => { + const withSearch = await callTool(fetchApifyApiOperation, { operationId: 'nope' }); + const withoutSearch = await callTool(fetchApifyApiOperation, { operationId: 'nope' }, [ + HELPER_TOOLS.API_OPERATION_FETCH, + ]); + + expectSoftFailInvalidInput(withSearch); + expect(withSearch.content[0].text).toBe( + `API operation nope not found. Find it with ${HELPER_TOOLS.API_SEARCH}.`, + ); + expect(withoutSearch.content[0].text).toBe('API operation nope not found.'); + }); +}); + +describe('read-apify-api', () => { + it('sends one GET to the filled-in path and returns the body as the API sends it', async () => { + const body = { data: { total: 1, items: [{ id: 'abc' }] } }; + requestMock.mockResolvedValue(mockResponse(200, body)); + + const result = await callTool(readApifyApi, { + operationId: 'dataset_items_get', + pathParams: { datasetId: 'abc' }, + query: { format: 'json', limit: 1 }, + }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + url: `${BASE_URL}/datasets/abc/items`, + method: 'GET', + params: { format: 'json', limit: 1 }, + maxContentLength: MAX_INLINE_BYTES, + signal: expect.any(AbortSignal), + }); + expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); + expect(result.structuredContent).toEqual({ + operationId: 'dataset_items_get', + method: 'GET', + path: '/v2/datasets/abc/items', + statusCode: 200, + contentType: 'application/json; charset=utf-8', + data: body, + }); + expect(result.content[1].text).toBe('GET /v2/datasets/abc/items returned HTTP 200.'); + }); + + it.each([ + ['dataset_put', 'dataset_put is a PUT operation with write access; this tool has read access.'], + ['actor_runSync_get', 'The API tools do not call actor_runSync_get. It waits up to 300 seconds'], + ['nope', 'API operation nope not found.'], + ])('refuses %s without a request', async (operationId, reason) => { + const result = await callTool(readApifyApi, { operationId, pathParams: { datasetId: 'abc', actorId: 'a' } }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain(reason); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('refuses bad path or query parameters without a request', async () => { + const badPath = await callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: '..' } }); + const badQuery = await callTool(readApifyApi, { + operationId: 'dataset_get', + pathParams: { datasetId: 'abc' }, + query: { token: 'secret' }, + }); + + expectSoftFailInvalidInput(badPath); + expectSoftFailInvalidInput(badQuery); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('throws a non-2xx response as the ApifyApiError apify-client builds', async () => { + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'record-not-found', message: 'Dataset was not found' } }), + ); + + const call = callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + + await expect(call).rejects.toBeInstanceOf(ApifyApiError); + await expect(call).rejects.toMatchObject({ + statusCode: 404, + type: 'record-not-found', + message: 'Dataset was not found', + }); + }); + + it('does not return a body over the inline limit', async () => { + requestMock.mockRejectedValue( + new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), + ); + + const result = await callTool(readApifyApi, { + operationId: 'dataset_items_get', + pathParams: { datasetId: 'abc' }, + query: { format: 'json' }, + }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain(`larger than ${MAX_INLINE_BYTES} bytes`); + expect(result.content[0].text).toContain('limit, offset, or fields'); + }); + + it('rethrows any other request failure', async () => { + requestMock.mockRejectedValue(new AxiosError('socket hang up', 'ECONNRESET')); + + await expect( + callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }), + ).rejects.toThrow('socket hang up'); + }); + + it('describes a binary body instead of returning it', async () => { + requestMock.mockResolvedValue(mockResponse(200, Buffer.from([1, 2, 3]), 'application/zip')); + + const result = await callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + + expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); + expect(result.structuredContent).toMatchObject({ contentType: 'application/zip', data: null }); + expect(result.content[1].text).toBe( + 'GET /v2/datasets/abc returned HTTP 200 with a binary body (application/zip, 3 bytes), which is not shown.', + ); + }); +}); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts new file mode 100644 index 000000000..a996d26cb --- /dev/null +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + API_ACCESS, + APIFY_API_OPENAPI_URL, + buildApiOperationIndex, + searchApiOperations, +} from '../../src/tools/api/apify_api_spec.js'; +import { API_SPEC_FIXTURE } from './helpers/apify_api_spec_fixture.js'; + +describe('buildApiOperationIndex()', () => { + const index = buildApiOperationIndex(API_SPEC_FIXTURE); + + it('leaves out deprecated operations, HEAD operations, paths outside /v2/ and entries without an ID', () => { + expect(index.has('actors_run_get')).toBe(false); + expect(index.has('dataset_items_head')).toBe(false); + expect(index.has('outside_get')).toBe(false); + expect([...index.values()].every((operation) => operation.operationId)).toBe(true); + expect(index.size).toBe(13); + }); + + it('resolves parameter references and keeps only path and query parameters', () => { + expect(index.get('dataset_items_get')?.parameters).toEqual([ + { + name: 'datasetId', + in: 'path', + isRequired: true, + description: 'Dataset ID or username~dataset-name.', + schema: { type: 'string' }, + }, + { name: 'limit', in: 'query', isRequired: false, schema: { type: 'integer' } }, + { name: 'format', in: 'query', isRequired: true, schema: { type: 'string' } }, + ]); + }); + + it('resolves the body schema, dropping examples but keeping a property named example', () => { + const body = index.get('dataset_put')?.requestBody; + expect(body?.isRequired).toBe(true); + expect(body?.schema).toEqual(API_SPEC_FIXTURE.components.schemas.UpdateDatasetRequest); + expect(index.get('keyValueStore_record_put')?.requestBody).toEqual({ + isRequired: true, + schema: { type: 'object', additionalProperties: true }, + }); + expect(index.get('actorRun_abort_post')?.requestBody).toBeUndefined(); + }); + + it('gives GET read access and POST and PUT write access', () => { + expect(index.get('dataset_get')?.access).toBe(API_ACCESS.READ); + expect(index.get('dataset_put')?.access).toBe(API_ACCESS.WRITE); + expect(index.get('actorRun_abort_post')?.access).toBe(API_ACCESS.WRITE); + }); + + it.each([ + ['dataset_delete', 'Deletion cannot be undone'], + ['actor_runSync_get', 'waits up to 300 seconds'], + ['users_me_limits_put', 'spending limits'], + ])('makes %s unavailable with the reason', (operationId, reason) => { + const operation = index.get(operationId); + expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); + expect(operation?.unavailableReason).toContain(reason); + }); + + it('refuses publishing, pricing, permission and sharing fields only where the body schema declares them', () => { + expect(index.get('actor_put')?.refusedBodyFields).toEqual(['isPublic', 'pricingInfos', 'actorPermissionLevel']); + expect(index.get('dataset_put')?.refusedBodyFields).toEqual(['generalAccess']); + // A record body is free-form: a record may hold an isPublic key as plain data. + expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); + }); + + it('returns an empty index for a document without paths', () => { + expect(buildApiOperationIndex({}).size).toBe(0); + expect(buildApiOperationIndex('not a spec').size).toBe(0); + }); +}); + +describe('searchApiOperations()', () => { + const index = buildApiOperationIndex(API_SPEC_FIXTURE); + + it('ranks summary matches first and breaks ties by the shorter path', () => { + const ids = searchApiOperations(index, 'update dataset', 10).map((operation) => operation.operationId); + expect(ids.slice(0, 2)).toEqual(['dataset_put', 'actorRun_dataset_put']); + }); + + it('matches word forms either way and ignores stop words', () => { + const ids = searchApiOperations(index, 'list all of the webhooks', 10).map( + (operation) => operation.operationId, + ); + expect(ids).toEqual(['webhooks_get', 'actor_webhooks_get']); + }); + + it('caps the results at the limit', () => { + expect(searchApiOperations(index, 'dataset', 2)).toHaveLength(2); + }); + + it('returns nothing when no keyword matches', () => { + expect(searchApiOperations(index, 'zebra', 10)).toEqual([]); + }); +}); + +describe('fetchApiOperationIndex()', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + // A fresh module graph per test, so the cache in state.ts starts empty. + vi.resetModules(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function importFetchApiOperationIndex() { + const module = await import('../../src/tools/api/apify_api_spec.js'); + return module.fetchApiOperationIndex; + } + + it('downloads the published spec once and serves later calls from the cache', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify(API_SPEC_FIXTURE))); + const fetchApiOperationIndex = await importFetchApiOperationIndex(); + + const [first, second] = await Promise.all([fetchApiOperationIndex(), fetchApiOperationIndex()]); + const third = await fetchApiOperationIndex(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(APIFY_API_OPENAPI_URL); + expect(first.has('dataset_get')).toBe(true); + expect(second).toBe(first); + expect(third).toBe(first); + }); + + it('throws on a failed download and tries again on the next call', async () => { + fetchMock + .mockResolvedValueOnce(new Response('unavailable', { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify(API_SPEC_FIXTURE))); + const fetchApiOperationIndex = await importFetchApiOperationIndex(); + + await expect(fetchApiOperationIndex()).rejects.toThrow('HTTP 503'); + await expect(fetchApiOperationIndex()).resolves.toSatisfy((index: Map) => + index.has('dataset_get'), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/tools.categories.test.ts b/tests/unit/tools.categories.test.ts index dc374666c..569cefa53 100644 --- a/tests/unit/tools.categories.test.ts +++ b/tests/unit/tools.categories.test.ts @@ -66,7 +66,15 @@ describe('getCategoryTools', () => { const defaultResult = getCategoryTools('default'); const appsResult = getCategoryTools('apps'); - const modeIndependentCategories: ToolCategory[] = ['docs', 'storage', 'tasks', 'schedules', 'builds', 'dev']; + const modeIndependentCategories: ToolCategory[] = [ + 'docs', + 'storage', + 'tasks', + 'schedules', + 'builds', + 'api', + 'dev', + ]; for (const cat of modeIndependentCategories) { expect(defaultResult[cat]).toEqual(appsResult[cat]); } diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 308d4f196..5d553125a 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -124,6 +124,12 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => { expect(toolNames(appsCategories.builds)).toEqual(expected); }); + it('should have correct tools in api category (both modes)', () => { + const expected = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_OPERATION_FETCH, HELPER_TOOLS.API_READ]; + expect(toolNames(defaultCategories.api)).toEqual(expected); + expect(toolNames(appsCategories.api)).toEqual(expected); + }); + it('should have correct tools in dev category (both modes)', () => { const expected = [HELPER_TOOLS.PROBLEM_REPORT]; expect(toolNames(defaultCategories.dev)).toEqual(expected); @@ -538,6 +544,7 @@ describe('tool descriptions never name a tool absent from the session', () => { { label: "tools: ['storage']", input: { tools: ['storage'] }, actorTools: [] }, { label: "tools: ['actors']", input: { tools: ['actors'] }, actorTools: [] }, { label: "tools: ['schedules']", input: { tools: ['schedules'] }, actorTools: [] }, + { label: "tools: ['api']", input: { tools: ['api'] }, actorTools: [] }, ...ALL_TOOL_NAMES.map((name) => ({ label: `tools: ['${name}']`, input: { tools: [name] }, From 76bc2221241bdf512e79128e65d4df0738186595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 23 Sep 2026 12:15:21 +0200 Subject: [PATCH 002/103] feat: Let an internal tool redact its own arguments in logs Every internal tool call logs its arguments: at DEBUG before AJV validation and at INFO as "Calling internal tool". The only redaction was the Skyfire payment ID, so a tool that takes secret values as arguments would write them to the logs. An internal tool can now set redactArgs on its entry. prepareToolCallContext applies it to the logged copy before the payment redaction, so both logs and every shell see the redacted arguments while the tool still gets the real ones. cloneToolEntry keeps the hook on the copies the payment providers decorate, since its JSON clone drops functions. (cherry picked from commit e64fcb93268940d8dd1eba0eba4ebbc106ff100b) --- src/payments/AGENTS.md | 8 +++++++- src/payments/helpers.ts | 8 +++++--- src/types.ts | 6 ++++++ src/utils/tools.ts | 4 ++++ tests/unit/payments.helpers.test.ts | 22 ++++++++++++++++++++++ tests/unit/tools.clone.test.ts | 7 +++++++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/payments/AGENTS.md b/src/payments/AGENTS.md index 2f987cf45..4301c5cea 100644 --- a/src/payments/AGENTS.md +++ b/src/payments/AGENTS.md @@ -24,7 +24,10 @@ The cross-file invariant no single file shows you: flow; forwards the `PAYMENT-SIGNATURE` header. - `const.ts` — `PAYMENT_PROTOCOL_HEADER` and the Skyfire instruction strings (x402's live in `x402.ts`). - `helpers.ts` — `prepareToolCallContext`: the choke point that strips payment fields - before AJV, redacts them for logging, and builds the client. + before AJV, redacts them for logging, and builds the client. The logged copy + (`toolArgsRedacted`, the `logSafeArgs` of both the pre-validation DEBUG log and the + `Calling internal tool` INFO log) first goes through the tool's own `redactArgs`, when an + internal tool has one. ## Rules when editing here @@ -36,6 +39,9 @@ The cross-file invariant no single file shows you: flag. `SKYFIRE_ENABLED_TOOLS` (`const.ts`) is the expected-list the Skyfire integration test asserts against; keep it in sync with the `paymentRequired` tools or that test fails. - `skyfire-pay-id` is injected only at the top level, so `redactSkyfirePayId` redacts top-level only; if you ever nest a payment field, make the redactor recursive or it leaks. +- A tool whose arguments carry secrets sets `redactArgs` on its entry (`HelperTool` in `../types.ts`) + instead of adding a case here. It runs before AJV validation, so it must accept any shape and must + not mutate the arguments; `cloneToolEntry` keeps it on the copies the providers decorate. ## Local commands diff --git a/src/payments/helpers.ts b/src/payments/helpers.ts index afae35c4f..a557a770c 100644 --- a/src/payments/helpers.ts +++ b/src/payments/helpers.ts @@ -1,6 +1,7 @@ import type { REQUEST_ORIGIN } from '../apify_client.js'; import { ApifyClient } from '../apify_client.js'; import type { ApifyToken, ToolEntry } from '../types.js'; +import { TOOL_TYPE } from '../types.js'; import { buildPaymentRequiredResponse, registerPaymentRequiredInterceptor } from '../utils/payment_errors.js'; import type { PaymentMeta, PaymentProvider, RequestHeaders } from './types.js'; @@ -25,7 +26,7 @@ export type PrepareToolCallContextResult = { * This helper centralizes all payment processing: * 1. Validates payment credentials (for tools with `paymentRequired: true`) * 2. Strips payment fields from args (for clean ajv validation and Actor input) - * 3. Redacts sensitive fields for logging + * 3. Redacts sensitive fields for logging: the tool's own `redactArgs`, then the payment fields * 4. Creates an ApifyClient with payment headers or standard token * * Call this BEFORE AJV validation so `toolArgsWithoutPayment` can be validated @@ -47,13 +48,14 @@ export function prepareToolCallContext(input: { requestOrigin?: REQUEST_ORIGIN; }): PrepareToolCallContextResult { const { provider, tool, args, apifyToken, meta, requestHeaders, requestOrigin } = input; + const toolRedactedArgs = tool.type === TOOL_TYPE.INTERNAL && tool.redactArgs ? tool.redactArgs(args) : args; if (!provider) { const apifyClient = new ApifyClient({ token: apifyToken, requestOrigin }); registerPaymentRequiredInterceptor(apifyClient); return { toolArgsWithoutPayment: { ...args }, - toolArgsRedacted: args, + toolArgsRedacted: toolRedactedArgs, apifyClient, }; } @@ -61,7 +63,7 @@ export function prepareToolCallContext(input: { const error = tool.paymentRequired ? provider.validatePayment(args, meta, requestHeaders) : null; const errorData = error && provider.getPaymentRequiredData ? provider.getPaymentRequiredData() : undefined; const toolArgsWithoutPayment = provider.removePaymentFields(args); - const toolArgsRedacted = provider.redactForLogging(args); + const toolArgsRedacted = provider.redactForLogging(toolRedactedArgs); const paymentHeaders = provider.getPaymentHeaders(args, meta, requestHeaders); const apifyClient = diff --git a/src/types.ts b/src/types.ts index 9cabad5d4..a886e8f49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,12 @@ export type HelperTool = ToolBase & { * @returns Promise resolving to the tool's output */ call: (toolArgs: InternalToolArgs) => Promise; + /** + * Returns the copy of the call's arguments that is logged, for a tool whose arguments carry + * secrets. Applied in `prepareToolCallContext`, so every log of the arguments sees it. It runs + * before AJV validation: it must accept arguments of any shape and must not mutate them. + */ + redactArgs?: (args: Record) => Record; }; /** diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 43fdb6a6b..547e19ab6 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -137,6 +137,7 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { // Store the original functions const originalAjvValidate = toolEntry.ajvValidate; const originalCall = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.call : undefined; + const originalRedactArgs = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.redactArgs : undefined; const originalBuildDescription = toolEntry.buildDescription; // Create a deep copy using JSON serialization (excluding functions) @@ -152,6 +153,9 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { if (toolEntry.type === TOOL_TYPE.INTERNAL && originalCall) { (cloned as HelperTool).call = originalCall; } + if (originalRedactArgs) { + (cloned as HelperTool).redactArgs = originalRedactArgs; + } if (originalBuildDescription) { cloned.buildDescription = originalBuildDescription; } diff --git a/tests/unit/payments.helpers.test.ts b/tests/unit/payments.helpers.test.ts index 4effa1307..402723555 100644 --- a/tests/unit/payments.helpers.test.ts +++ b/tests/unit/payments.helpers.test.ts @@ -164,6 +164,28 @@ describe('prepareToolCallContext — Skyfire-like provider', () => { expect((result.toolArgsRedacted as Record)['skyfire-pay-id']).toBe('[REDACTED]'); }); + it("applies the tool's redactArgs and then the payment redaction to logSafeArgs", () => { + const args = { actor: 'apify/rag-web-browser', secret: 'hide-me', 'skyfire-pay-id': 'jwt-token-123' }; + const tool = { + ...makeTool(true), + redactArgs: (toolArgs: Record) => ({ ...toolArgs, secret: '[REDACTED]' }), + }; + const result = prepareToolCallContext({ + provider: makeSkyfireLikeProvider(), + tool, + args, + apifyToken: MOCK_APIFY_TOKEN, + }); + + expect(result.toolArgsRedacted).toEqual({ + actor: 'apify/rag-web-browser', + secret: '[REDACTED]', + 'skyfire-pay-id': '[REDACTED]', + }); + // The tool still gets the real value. + expect(result.toolArgsWithoutPayment).toEqual({ actor: 'apify/rag-web-browser', secret: 'hide-me' }); + }); + it('returns paymentRequiredResult when payment field is missing', () => { const args = { actor: 'apify/rag-web-browser' }; // no skyfire-pay-id const result = prepareToolCallContext({ diff --git a/tests/unit/tools.clone.test.ts b/tests/unit/tools.clone.test.ts index 61c167fc6..7118710a3 100644 --- a/tests/unit/tools.clone.test.ts +++ b/tests/unit/tools.clone.test.ts @@ -87,6 +87,13 @@ describe('cloneToolEntry', () => { expect(typeof cloned.call).toBe('function'); }); + it('keeps redactArgs, so a payment-decorated copy still redacts its logs', () => { + const redactArgs = vi.fn((args: Record) => args); + const cloned = cloneToolEntry(makeInternalTool({ redactArgs })) as HelperTool; + + expect(cloned.redactArgs).toBe(redactArgs); + }); + it('should work for actor tools (no call function)', () => { const original = makeActorTool(); const cloned = cloneToolEntry(original); From be629e36cc153347a4d1157e4a2dad2b3aede042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 25 Sep 2026 12:48:56 +0200 Subject: [PATCH 003/103] feat: Add write-apify-api to call POST and PUT API operations write-apify-api calls one Apify API operation with write access by its operation ID, with the same path and query checks as read-apify-api and a JSON body. It refuses what the index marks unavailable (deletions, synchronous runs, spending limits, run charging) and body fields that publish an Actor or task, change its pricing or permissions, or change who can read a storage. Those fields are refused only where the operation's body schema declares them, so a stored record can still hold them as data. The body can carry secrets such as environment variable values, so the tool redacts it in logs through redactArgs. Each call tool now names the other one when an operation needs the other access and the session has it. --- README.md | 3 +- src/const.ts | 1 + src/tools/AGENTS.md | 8 +- src/tools/api/apify_api_request.ts | 37 ++++++-- src/tools/api/search_apify_api.ts | 5 +- src/tools/api/write_apify_api.ts | 89 ++++++++++++++++++ src/tools/registry.ts | 3 +- src/tools/structured_output_schemas.ts | 2 +- tests/unit/tools.apify_api.test.ts | 123 +++++++++++++++++++++++++ tests/unit/tools.mode_contract.test.ts | 7 +- 10 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/tools/api/write_apify_api.ts diff --git a/README.md b/README.md index b97776e21..dfa7fc038 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them: - **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages. - **Schedules**: Create, inspect, update, and delete schedules that run your Actors and tasks automatically. - **Builds**: Build an Actor version, check the status of a build, and retrieve its build log. -- **Apify API**: Search the Apify API reference, inspect an operation, and call read-only operations. +- **Apify API**: Search the Apify API reference, inspect an operation, and call it to read or change data. ### Overview of available tools @@ -297,6 +297,7 @@ Legend for the **Enabled by default** column: | `search-apify-api` | api | Search the Apify API reference for operations by keywords. | | | `fetch-apify-api-operation` | api | Get an Apify API operation's parameters and request body schema. | | | `read-apify-api` | api | Call a read-only (GET) Apify API operation by its operation ID. | | +| `write-apify-api` | api | Call a POST or PUT Apify API operation by its operation ID. Deletions, publishing, and pricing changes are refused. | | > **Note:** > diff --git a/src/const.ts b/src/const.ts index fe72c7206..72ada7b5a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -76,6 +76,7 @@ export const HELPER_TOOLS = { API_SEARCH: 'search-apify-api', API_OPERATION_FETCH: 'fetch-apify-api-operation', API_READ: 'read-apify-api', + API_WRITE: 'write-apify-api', PROBLEM_REPORT: 'report-problem', } as const; export type HelperToolName = (typeof HELPER_TOOLS)[keyof typeof HELPER_TOOLS]; diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index a662afaa0..a5ed2510d 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -33,9 +33,11 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get one - operation, and call a GET operation. `apify_api_spec.ts` builds the operation index from the spec - (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason. - `apify_api_request.ts` fills in the path, checks the query, and sends one request with no retries. + operation, and call it with read access (GET) or write access (POST, PUT). `apify_api_spec.ts` builds + the operation index from the spec (cached for an hour) and decides each operation's access: read, + write, or unavailable with a reason, plus the body fields the write tool refuses. + `apify_api_request.ts` fills in the path, checks the query and body, and sends one request with no + retries. `write-apify-api` redacts its `body` in logs through `redactArgs`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 7169b65aa..d6b5ad63a 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -9,7 +9,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import type { ApiAccess, ApiOperation } from './apify_api_spec.js'; -import { API_ACCESS } from './apify_api_spec.js'; +import { API_ACCESS, isRecord } from './apify_api_spec.js'; /** Input fields the read and write tools share. */ export const apiCallArgsShape = { @@ -34,6 +34,8 @@ export const apiCallArgsShape = { export type ApiCallParams = { query?: Record; + /** JSON request body; only the write tool sends one. */ + body?: unknown; /** Aborts the request when the client cancels the tool call. */ signal?: AbortSignal; }; @@ -57,8 +59,10 @@ export function resolveOperationToCall( return { error: `The API tools do not call ${operationId}. ${operation.unavailableReason}` }; } if (operation.access !== access) { + const otherTool = operation.access === API_ACCESS.READ ? HELPER_TOOLS.API_READ : HELPER_TOOLS.API_WRITE; + const callOther = loadedToolNames.includes(otherTool) ? ` Call it with ${otherTool}.` : ''; return { - error: `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ${access} access.`, + error: `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ${access} access.${callOther}`, }; } return { operation }; @@ -115,11 +119,31 @@ export function validateQueryParams( return undefined; } +/** Checks the body against the operation; returns the reason on failure. */ +export function validateRequestBody(operation: ApiOperation, body: unknown): string | undefined { + if (!operation.requestBody) { + return body === undefined ? undefined : `${operation.operationId} takes no request body.`; + } + if (body === undefined) { + return operation.requestBody.isRequired ? `${operation.operationId} needs a request body.` : undefined; + } + if (!isRecord(body)) return undefined; + const refusedFields = operation.refusedBodyFields.filter((field) => field in body); + if (refusedFields.length > 0) { + return ( + `The API tools do not set ${refusedFields.join(', ')}, whatever the value: publishing, pricing, ` + + 'permission, and sharing changes need a dedicated tool or Apify Console. Remove the field and call again.' + ); + } + return undefined; +} + function formatOversizeMessage(operation: ApiOperation, path: string): string { - return ( - `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ` + - 'Narrow the request, for example with the limit, offset, or fields query parameters if the operation declares them.' - ); + const next = + operation.method === 'GET' + ? 'Narrow the request, for example with the limit, offset, or fields query parameters if the operation declares them.' + : 'The request itself was sent; check its effect with an operation with read access.'; + return `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ${next}`; } /** @@ -144,6 +168,7 @@ export async function callApiOperation( url: `${client.baseUrl}${path.slice('/v2'.length)}`, method: operation.method, params: params.query, + data: params.body, maxContentLength: MAX_INLINE_BYTES, signal: params.signal, }); diff --git a/src/tools/api/search_apify_api.ts b/src/tools/api/search_apify_api.ts index c5ff15ce8..297236bc2 100644 --- a/src/tools/api/search_apify_api.ts +++ b/src/tools/api/search_apify_api.ts @@ -24,7 +24,10 @@ const searchApifyApiArgs = z.object({ }); function buildDescription({ hasTool }: ToolDescriptionContext): string { - const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); + const callTools = [ + hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`, + hasTool(HELPER_TOOLS.API_WRITE) && `${HELPER_TOOLS.API_WRITE} for write access`, + ].filter(Boolean); const nextSteps = [ hasTool(HELPER_TOOLS.API_OPERATION_FETCH) && `get its parameters with ${HELPER_TOOLS.API_OPERATION_FETCH}`, callTools.length > 0 && `call it with ${callTools.join(' or ')}`, diff --git a/src/tools/api/write_apify_api.ts b/src/tools/api/write_apify_api.ts new file mode 100644 index 000000000..53e66bc97 --- /dev/null +++ b/src/tools/api/write_apify_api.ts @@ -0,0 +1,89 @@ +import dedent from 'dedent'; +import { z } from 'zod'; + +import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; +import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; +import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; +import { compileSchema } from '../../utils/ajv.js'; +import { respondUserError } from '../../utils/mcp.js'; +import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; +import { + apiCallArgsShape, + buildRequestPath, + callApiOperation, + resolveOperationToCall, + validateQueryParams, + validateRequestBody, +} from './apify_api_request.js'; +import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; + +const writeApifyApiArgs = z.object({ + ...apiCallArgsShape, + body: z + .unknown() + .optional() + .describe('The request body, sent as JSON. Required when the operation needs one; omit it otherwise.'), +}); + +function buildDescription({ hasTool }: ToolDescriptionContext): string { + const getParameters = hasTool(HELPER_TOOLS.API_OPERATION_FETCH) + ? `\nGet the operation's parameters and body schema first with ${HELPER_TOOLS.API_OPERATION_FETCH}.` + : ''; + return dedent` + Call one Apify API operation with write access (POST or PUT), by its operation ID. The server builds + the URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} + The request is sent once and applies at once. Refused: deletions, synchronous runs, spending limits, + run charging, and body fields that publish an Actor or task, change its pricing or permissions, or + change who can read a storage. Returns the response body as the API sends it; a body over + ${MAX_INLINE_BYTES} bytes is not returned. + + USAGE: + - Use to change something no dedicated tool changes, such as a dataset's name or a webhook. + - Change only what the user asked for. + + USAGE EXAMPLES: + - user_input: Rename my dataset to leads-2026 + - user_input: Add a webhook that calls https://example.com/hook when a run of my-actor fails + `; +} + +/** + * Calls one POST or PUT operation of the published Apify API spec, https://docs.apify.com/api/v2. + */ +export const writeApifyApi: ToolEntry = Object.freeze({ + type: TOOL_TYPE.INTERNAL, + name: HELPER_TOOLS.API_WRITE, + title: 'Write Apify API', + description: buildDescription(ALL_TOOLS_PRESENT), + buildDescription, + inputSchema: z.toJSONSchema(writeApifyApiArgs) as ToolInputSchema, + outputSchema: apifyApiCallOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(writeApifyApiArgs)), + annotations: { + title: 'Write Apify API', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + // A body can carry secrets: environment variable values, webhook headers, stored records. + redactArgs: (args: Record) => ('body' in args ? { ...args, body: '[REDACTED]' } : args), + call: async (toolArgs: InternalToolArgs) => { + const parsed = writeApifyApiArgs.parse(toolArgs.args); + const index = await fetchApiOperationIndex(); + const resolved = resolveOperationToCall(index, parsed.operationId, API_ACCESS.WRITE, toolArgs.loadedToolNames); + if ('error' in resolved) return respondUserError(resolved.error); + const { operation } = resolved; + + const request = buildRequestPath(operation, parsed.pathParams); + if ('error' in request) return respondUserError(request.error); + const inputError = validateQueryParams(operation, parsed.query) ?? validateRequestBody(operation, parsed.body); + if (inputError) return respondUserError(inputError); + + return callApiOperation(toolArgs.apifyClient, operation, request.path, { + query: parsed.query, + body: parsed.body, + signal: toolArgs.signal, + }); + }, +} as const); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 41ce3a616..a897d3867 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -23,6 +23,7 @@ import { searchActors } from './actors/search_actors.js'; import { fetchApifyApiOperation } from './api/fetch_apify_api_operation.js'; import { readApifyApi } from './api/read_apify_api.js'; import { searchApifyApi } from './api/search_apify_api.js'; +import { writeApifyApi } from './api/write_apify_api.js'; import { buildActor } from './builds/build_actor.js'; import { getActorBuild } from './builds/get_actor_build.js'; import { getActorBuildLog } from './builds/get_actor_build_log.js'; @@ -73,7 +74,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, buildActor], - api: [searchApifyApi, fetchApifyApiOperation, readApifyApi], + api: [searchApifyApi, fetchApifyApiOperation, readApifyApi, writeApifyApi], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 033a4cf35..c0a4129d0 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -960,7 +960,7 @@ export const apifyApiOperationOutputSchema = { }; /** - * Schema for the response of an API call (read-apify-api). + * Schema for the response of an API call (read-apify-api, write-apify-api). */ export const apifyApiCallOutputSchema = { type: 'object' as const, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index e404cae16..7a7496d8d 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -9,6 +9,7 @@ import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; import { fetchApifyApiOperation } from '../../src/tools/api/fetch_apify_api_operation.js'; import { readApifyApi } from '../../src/tools/api/read_apify_api.js'; import { searchApifyApi } from '../../src/tools/api/search_apify_api.js'; +import { writeApifyApi } from '../../src/tools/api/write_apify_api.js'; import { apifyApiCallOutputSchema, apifyApiOperationOutputSchema, @@ -204,6 +205,15 @@ describe('read-apify-api', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('names the write tool for a write operation only when the session has it', async () => { + const args = { operationId: 'dataset_put', pathParams: { datasetId: 'abc' } }; + const withWrite = await callTool(readApifyApi, args); + const withoutWrite = await callTool(readApifyApi, args, [HELPER_TOOLS.API_READ]); + + expect(withWrite.content[0].text).toContain(`Call it with ${HELPER_TOOLS.API_WRITE}.`); + expect(withoutWrite.content[0].text).not.toContain(HELPER_TOOLS.API_WRITE); + }); + it('refuses bad path or query parameters without a request', async () => { const badPath = await callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: '..' } }); const badQuery = await callTool(readApifyApi, { @@ -268,3 +278,116 @@ describe('read-apify-api', () => { ); }); }); + +describe('write-apify-api', () => { + it('sends one request with the JSON body and returns the response', async () => { + const body = { data: { id: 'abc', name: 'leads-2026' } }; + requestMock.mockResolvedValue(mockResponse(200, body)); + + const result = await callTool(writeApifyApi, { + operationId: 'dataset_put', + pathParams: { datasetId: 'abc' }, + body: { name: 'leads-2026' }, + }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + url: `${BASE_URL}/datasets/abc`, + method: 'PUT', + params: undefined, + data: { name: 'leads-2026' }, + maxContentLength: MAX_INLINE_BYTES, + signal: expect.any(AbortSignal), + }); + expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); + expect(result.structuredContent).toMatchObject({ method: 'PUT', path: '/v2/datasets/abc', data: body }); + }); + + it('sends an operation without a body when none is given', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: { id: 'run-1', status: 'ABORTING' } })); + + await callTool(writeApifyApi, { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' } }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', data: undefined })); + }); + + it.each([ + [{ operationId: 'dataset_delete', pathParams: { datasetId: 'abc' } }, 'Deletion cannot be undone'], + [{ operationId: 'users_me_limits_put', body: {} }, 'spending limits'], + [ + { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, + `dataset_get is a GET operation with read access; this tool has write access. Call it with ${HELPER_TOOLS.API_READ}.`, + ], + [{ operationId: 'dataset_put', pathParams: { datasetId: 'abc' } }, 'dataset_put needs a request body.'], + [ + { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' }, body: { gracefully: true } }, + 'actorRun_abort_post takes no request body.', + ], + [ + { + operationId: 'actor_put', + pathParams: { actorId: 'john~my-actor' }, + body: { title: 'T', isPublic: false }, + }, + 'The API tools do not set isPublic, whatever the value', + ], + [ + { + operationId: 'dataset_put', + pathParams: { datasetId: 'abc' }, + body: { generalAccess: 'ANYONE_WITH_ID_CAN_READ' }, + }, + 'The API tools do not set generalAccess', + ], + ])('refuses %j without a request', async (args, reason) => { + const result = await callTool(writeApifyApi, args); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain(reason); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('sends a refused field name when the body is free-form, such as a stored record', async () => { + requestMock.mockResolvedValue(mockResponse(201, undefined, '')); + + const result = await callTool(writeApifyApi, { + operationId: 'keyValueStore_record_put', + pathParams: { storeId: 'store-1', recordKey: 'CONFIG' }, + body: { isPublic: true }, + }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ data: { isPublic: true } })); + expect(result.structuredContent).toMatchObject({ statusCode: 201, data: null }); + }); + + it('says the request was sent when the response is over the inline limit', async () => { + requestMock.mockRejectedValue( + new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), + ); + + const result = await callTool(writeApifyApi, { + operationId: 'dataset_put', + pathParams: { datasetId: 'abc' }, + body: { name: 'leads-2026' }, + }); + + expect(result.content[0].text).toContain('The request itself was sent'); + }); + + describe('redactArgs()', () => { + const { redactArgs } = writeApifyApi as HelperTool; + + it('redacts the body in the logged copy without changing the arguments', () => { + const args = { operationId: 'actor_version_envVar_put', body: { value: 'secret' } }; + + expect(redactArgs?.(args)).toEqual({ operationId: 'actor_version_envVar_put', body: '[REDACTED]' }); + expect(args.body).toEqual({ value: 'secret' }); + }); + + it('leaves arguments without a body as they are', () => { + const args = { operationId: 'actorRun_abort_post' }; + + expect(redactArgs?.(args)).toBe(args); + }); + }); +}); diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 5d553125a..8b55c1508 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -125,7 +125,12 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => { }); it('should have correct tools in api category (both modes)', () => { - const expected = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_OPERATION_FETCH, HELPER_TOOLS.API_READ]; + const expected = [ + HELPER_TOOLS.API_SEARCH, + HELPER_TOOLS.API_OPERATION_FETCH, + HELPER_TOOLS.API_READ, + HELPER_TOOLS.API_WRITE, + ]; expect(toolNames(defaultCategories.api)).toEqual(expected); expect(toolNames(appsCategories.api)).toEqual(expected); }); From 2fad461b58f6ee7d84e3e5d8c6c5ab269691746c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 22:36:04 +0200 Subject: [PATCH 004/103] refactor: Rename the Apify API tools to the apify-api-* family The tools follow the naming of the Stripe MCP server's stripe_api_* tools, so they sort together and read as one family: - search-apify-api is now apify-api-search. - fetch-apify-api-operation is now apify-api-details. - read-apify-api is now apify-api-read. The HELPER_TOOLS key API_OPERATION_FETCH is now API_DETAILS, and the tool files and exported constants follow the new names. The tools are not released yet, so nothing else changes. --- README.md | 6 +-- src/const.ts | 6 +-- ..._api_operation.ts => apify_api_details.ts} | 20 ++++----- .../{read_apify_api.ts => apify_api_read.ts} | 14 +++--- ...earch_apify_api.ts => apify_api_search.ts} | 12 ++--- src/tools/registry.ts | 8 ++-- src/tools/structured_output_schemas.ts | 8 ++-- tests/unit/tools.apify_api.test.ts | 44 +++++++++---------- tests/unit/tools.mode_contract.test.ts | 2 +- 9 files changed, 59 insertions(+), 61 deletions(-) rename src/tools/api/{fetch_apify_api_operation.ts => apify_api_details.ts} (82%) rename src/tools/api/{read_apify_api.ts => apify_api_read.ts} (87%) rename src/tools/api/{search_apify_api.ts => apify_api_search.ts} (89%) diff --git a/README.md b/README.md index b97776e21..cfe2ebf7b 100644 --- a/README.md +++ b/README.md @@ -294,9 +294,9 @@ Legend for the **Enabled by default** column: | `get-actor-build` | builds | Get an Actor build's status. | | | `get-actor-build-log` | builds | Retrieve the logs for a specific Actor build. | | | `build-actor` | builds | Build an Actor version and wait a bounded time for the build to finish. | | -| `search-apify-api` | api | Search the Apify API reference for operations by keywords. | | -| `fetch-apify-api-operation` | api | Get an Apify API operation's parameters and request body schema. | | -| `read-apify-api` | api | Call a read-only (GET) Apify API operation by its operation ID. | | +| `apify-api-search` | api | Search the Apify API reference for operations by keywords. | | +| `apify-api-details` | api | Get an Apify API operation's parameters and request body schema. | | +| `apify-api-read` | api | Call a read-only (GET) Apify API operation by its operation ID. | | > **Note:** > diff --git a/src/const.ts b/src/const.ts index fe72c7206..a32294609 100644 --- a/src/const.ts +++ b/src/const.ts @@ -73,9 +73,9 @@ export const HELPER_TOOLS = { STORE_SEARCH_WIDGET: 'search-actors-widget', DOCS_SEARCH: 'search-apify-docs', DOCS_FETCH: 'fetch-apify-docs', - API_SEARCH: 'search-apify-api', - API_OPERATION_FETCH: 'fetch-apify-api-operation', - API_READ: 'read-apify-api', + API_SEARCH: 'apify-api-search', + API_DETAILS: 'apify-api-details', + API_READ: 'apify-api-read', PROBLEM_REPORT: 'report-problem', } as const; export type HelperToolName = (typeof HELPER_TOOLS)[keyof typeof HELPER_TOOLS]; diff --git a/src/tools/api/fetch_apify_api_operation.ts b/src/tools/api/apify_api_details.ts similarity index 82% rename from src/tools/api/fetch_apify_api_operation.ts rename to src/tools/api/apify_api_details.ts index 114d7775a..0cc8cd3d2 100644 --- a/src/tools/api/fetch_apify_api_operation.ts +++ b/src/tools/api/apify_api_details.ts @@ -6,11 +6,11 @@ import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSche import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; -import { apifyApiOperationOutputSchema } from '../structured_output_schemas.js'; +import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; import { apiCallArgsShape, formatOperationNotFoundMessage } from './apify_api_request.js'; import { fetchApiOperationIndex } from './apify_api_spec.js'; -const fetchApifyApiOperationArgs = z.object({ +const apifyApiDetailsArgs = z.object({ operationId: apiCallArgsShape.operationId, }); @@ -35,24 +35,24 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * Returns one operation of the published Apify API spec, https://docs.apify.com/api/v2. */ -export const fetchApifyApiOperation: ToolEntry = Object.freeze({ +export const apifyApiDetails: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, - name: HELPER_TOOLS.API_OPERATION_FETCH, - title: 'Fetch Apify API operation', + name: HELPER_TOOLS.API_DETAILS, + title: 'Get Apify API operation details', description: buildDescription(ALL_TOOLS_PRESENT), buildDescription, - inputSchema: z.toJSONSchema(fetchApifyApiOperationArgs) as ToolInputSchema, - outputSchema: apifyApiOperationOutputSchema, - ajvValidate: compileSchema(z.toJSONSchema(fetchApifyApiOperationArgs)), + inputSchema: z.toJSONSchema(apifyApiDetailsArgs) as ToolInputSchema, + outputSchema: apifyApiDetailsOutputSchema, + ajvValidate: compileSchema(z.toJSONSchema(apifyApiDetailsArgs)), annotations: { - title: 'Fetch Apify API operation', + title: 'Get Apify API operation details', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const parsed = fetchApifyApiOperationArgs.parse(toolArgs.args); + const parsed = apifyApiDetailsArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); const operation = index.get(parsed.operationId); if (!operation) { diff --git a/src/tools/api/read_apify_api.ts b/src/tools/api/apify_api_read.ts similarity index 87% rename from src/tools/api/read_apify_api.ts rename to src/tools/api/apify_api_read.ts index b2cd56bf1..138bf822a 100644 --- a/src/tools/api/read_apify_api.ts +++ b/src/tools/api/apify_api_read.ts @@ -16,11 +16,11 @@ import { } from './apify_api_request.js'; import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; -const readApifyApiArgs = z.object(apiCallArgsShape); +const apifyApiReadArgs = z.object(apiCallArgsShape); function buildDescription({ hasTool }: ToolDescriptionContext): string { - const getParameters = hasTool(HELPER_TOOLS.API_OPERATION_FETCH) - ? `\nGet the operation's parameters first with ${HELPER_TOOLS.API_OPERATION_FETCH}.` + const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) + ? `\nGet the operation's parameters first with ${HELPER_TOOLS.API_DETAILS}.` : ''; return dedent` Call one Apify API operation with read access (GET), by its operation ID. The server builds the @@ -40,15 +40,15 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * Calls one GET operation of the published Apify API spec, https://docs.apify.com/api/v2. */ -export const readApifyApi: ToolEntry = Object.freeze({ +export const apifyApiRead: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, name: HELPER_TOOLS.API_READ, title: 'Read Apify API', description: buildDescription(ALL_TOOLS_PRESENT), buildDescription, - inputSchema: z.toJSONSchema(readApifyApiArgs) as ToolInputSchema, + inputSchema: z.toJSONSchema(apifyApiReadArgs) as ToolInputSchema, outputSchema: apifyApiCallOutputSchema, - ajvValidate: compileSchema(z.toJSONSchema(readApifyApiArgs)), + ajvValidate: compileSchema(z.toJSONSchema(apifyApiReadArgs)), annotations: { title: 'Read Apify API', readOnlyHint: true, @@ -57,7 +57,7 @@ export const readApifyApi: ToolEntry = Object.freeze({ openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const parsed = readApifyApiArgs.parse(toolArgs.args); + const parsed = apifyApiReadArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); const resolved = resolveOperationToCall(index, parsed.operationId, API_ACCESS.READ, toolArgs.loadedToolNames); if ('error' in resolved) return respondUserError(resolved.error); diff --git a/src/tools/api/search_apify_api.ts b/src/tools/api/apify_api_search.ts similarity index 89% rename from src/tools/api/search_apify_api.ts rename to src/tools/api/apify_api_search.ts index c5ff15ce8..6b4f414e1 100644 --- a/src/tools/api/search_apify_api.ts +++ b/src/tools/api/apify_api_search.ts @@ -9,7 +9,7 @@ import { respondOk } from '../../utils/mcp.js'; import { apifyApiSearchOutputSchema } from '../structured_output_schemas.js'; import { fetchApiOperationIndex, searchApiOperations } from './apify_api_spec.js'; -const searchApifyApiArgs = z.object({ +const apifyApiSearchArgs = z.object({ query: z .string() .min(1) @@ -26,7 +26,7 @@ const searchApifyApiArgs = z.object({ function buildDescription({ hasTool }: ToolDescriptionContext): string { const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); const nextSteps = [ - hasTool(HELPER_TOOLS.API_OPERATION_FETCH) && `get its parameters with ${HELPER_TOOLS.API_OPERATION_FETCH}`, + hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS}`, callTools.length > 0 && `call it with ${callTools.join(' or ')}`, ].filter(Boolean); const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; @@ -49,15 +49,15 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * Searches the operations of the published Apify API spec, https://docs.apify.com/api/v2. */ -export const searchApifyApi: ToolEntry = Object.freeze({ +export const apifyApiSearch: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, name: HELPER_TOOLS.API_SEARCH, title: 'Search Apify API', description: buildDescription(ALL_TOOLS_PRESENT), buildDescription, - inputSchema: fixZodSchemaRequired(z.toJSONSchema(searchApifyApiArgs)) as ToolInputSchema, + inputSchema: fixZodSchemaRequired(z.toJSONSchema(apifyApiSearchArgs)) as ToolInputSchema, outputSchema: apifyApiSearchOutputSchema, - ajvValidate: compileSchema(fixZodSchemaRequired(z.toJSONSchema(searchApifyApiArgs))), + ajvValidate: compileSchema(fixZodSchemaRequired(z.toJSONSchema(apifyApiSearchArgs))), annotations: { title: 'Search Apify API', readOnlyHint: true, @@ -66,7 +66,7 @@ export const searchApifyApi: ToolEntry = Object.freeze({ openWorldHint: false, }, call: async (toolArgs: InternalToolArgs) => { - const parsed = searchApifyApiArgs.parse(toolArgs.args); + const parsed = apifyApiSearchArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); const operations = searchApiOperations(index, parsed.query, parsed.limit).map((operation) => ({ operationId: operation.operationId, diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 41ce3a616..08dbefec8 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -20,9 +20,9 @@ import { SERVER_MODE } from '../types.js'; import { callActor } from './actors/call_actor.js'; import { fetchActorDetails } from './actors/fetch_actor_details.js'; import { searchActors } from './actors/search_actors.js'; -import { fetchApifyApiOperation } from './api/fetch_apify_api_operation.js'; -import { readApifyApi } from './api/read_apify_api.js'; -import { searchApifyApi } from './api/search_apify_api.js'; +import { apifyApiDetails } from './api/apify_api_details.js'; +import { apifyApiRead } from './api/apify_api_read.js'; +import { apifyApiSearch } from './api/apify_api_search.js'; import { buildActor } from './builds/build_actor.js'; import { getActorBuild } from './builds/get_actor_build.js'; import { getActorBuildLog } from './builds/get_actor_build_log.js'; @@ -73,7 +73,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, buildActor], - api: [searchApifyApi, fetchApifyApiOperation, readApifyApi], + api: [apifyApiSearch, apifyApiDetails, apifyApiRead], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 033a4cf35..5bf4e05b9 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -889,7 +889,7 @@ const apiUnavailableReasonProperty = { }; /** - * Schema for the operations found by search-apify-api. + * Schema for the operations found by apify-api-search. */ export const apifyApiSearchOutputSchema = { type: 'object' as const, @@ -914,9 +914,9 @@ export const apifyApiSearchOutputSchema = { }; /** - * Schema for one operation's details (fetch-apify-api-operation). + * Schema for one operation's details (apify-api-details). */ -export const apifyApiOperationOutputSchema = { +export const apifyApiDetailsOutputSchema = { type: 'object' as const, properties: { operationId: { type: 'string', description: 'Operation ID' }, @@ -960,7 +960,7 @@ export const apifyApiOperationOutputSchema = { }; /** - * Schema for the response of an API call (read-apify-api). + * Schema for the response of an API call (apify-api-read). */ export const apifyApiCallOutputSchema = { type: 'object' as const, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index e404cae16..33c03e8ef 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -3,15 +3,15 @@ import { AxiosError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; +import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; +import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; import { buildRequestPath, validateQueryParams } from '../../src/tools/api/apify_api_request.js'; +import { apifyApiSearch } from '../../src/tools/api/apify_api_search.js'; import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; -import { fetchApifyApiOperation } from '../../src/tools/api/fetch_apify_api_operation.js'; -import { readApifyApi } from '../../src/tools/api/read_apify_api.js'; -import { searchApifyApi } from '../../src/tools/api/search_apify_api.js'; import { apifyApiCallOutputSchema, - apifyApiOperationOutputSchema, + apifyApiDetailsOutputSchema, apifyApiSearchOutputSchema, } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; @@ -104,9 +104,9 @@ describe('validateQueryParams()', () => { }); }); -describe('search-apify-api', () => { +describe('apify-api-search', () => { it('returns the matching operations with their access', async () => { - const result = await callTool(searchApifyApi, { query: 'delete dataset', limit: 1 }); + const result = await callTool(apifyApiSearch, { query: 'delete dataset', limit: 1 }); expectSchemaConformingStructuredContent(result, apifyApiSearchOutputSchema); expect(result.structuredContent).toEqual({ @@ -124,18 +124,18 @@ describe('search-apify-api', () => { }); it('says so when nothing matches', async () => { - const result = await callTool(searchApifyApi, { query: 'zebra' }); + const result = await callTool(apifyApiSearch, { query: 'zebra' }); expect(result.structuredContent).toEqual({ operations: [] }); expect(result.content[1].text).toBe('No API operation matches "zebra". Try other keywords.'); }); }); -describe('fetch-apify-api-operation', () => { +describe('apify-api-details', () => { it('returns the parameters, body schema and refused fields', async () => { - const result = await callTool(fetchApifyApiOperation, { operationId: 'actor_put' }); + const result = await callTool(apifyApiDetails, { operationId: 'actor_put' }); - expectSchemaConformingStructuredContent(result, apifyApiOperationOutputSchema); + expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); expect(result.structuredContent).toMatchObject({ operationId: 'actor_put', method: 'PUT', @@ -148,10 +148,8 @@ describe('fetch-apify-api-operation', () => { }); it('names the search tool on an unknown operation only when the session has it', async () => { - const withSearch = await callTool(fetchApifyApiOperation, { operationId: 'nope' }); - const withoutSearch = await callTool(fetchApifyApiOperation, { operationId: 'nope' }, [ - HELPER_TOOLS.API_OPERATION_FETCH, - ]); + const withSearch = await callTool(apifyApiDetails, { operationId: 'nope' }); + const withoutSearch = await callTool(apifyApiDetails, { operationId: 'nope' }, [HELPER_TOOLS.API_DETAILS]); expectSoftFailInvalidInput(withSearch); expect(withSearch.content[0].text).toBe( @@ -161,12 +159,12 @@ describe('fetch-apify-api-operation', () => { }); }); -describe('read-apify-api', () => { +describe('apify-api-read', () => { it('sends one GET to the filled-in path and returns the body as the API sends it', async () => { const body = { data: { total: 1, items: [{ id: 'abc' }] } }; requestMock.mockResolvedValue(mockResponse(200, body)); - const result = await callTool(readApifyApi, { + const result = await callTool(apifyApiRead, { operationId: 'dataset_items_get', pathParams: { datasetId: 'abc' }, query: { format: 'json', limit: 1 }, @@ -197,7 +195,7 @@ describe('read-apify-api', () => { ['actor_runSync_get', 'The API tools do not call actor_runSync_get. It waits up to 300 seconds'], ['nope', 'API operation nope not found.'], ])('refuses %s without a request', async (operationId, reason) => { - const result = await callTool(readApifyApi, { operationId, pathParams: { datasetId: 'abc', actorId: 'a' } }); + const result = await callTool(apifyApiRead, { operationId, pathParams: { datasetId: 'abc', actorId: 'a' } }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toContain(reason); @@ -205,8 +203,8 @@ describe('read-apify-api', () => { }); it('refuses bad path or query parameters without a request', async () => { - const badPath = await callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: '..' } }); - const badQuery = await callTool(readApifyApi, { + const badPath = await callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: '..' } }); + const badQuery = await callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' }, query: { token: 'secret' }, @@ -222,7 +220,7 @@ describe('read-apify-api', () => { mockResponse(404, { error: { type: 'record-not-found', message: 'Dataset was not found' } }), ); - const call = callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + const call = callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); await expect(call).rejects.toBeInstanceOf(ApifyApiError); await expect(call).rejects.toMatchObject({ @@ -237,7 +235,7 @@ describe('read-apify-api', () => { new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), ); - const result = await callTool(readApifyApi, { + const result = await callTool(apifyApiRead, { operationId: 'dataset_items_get', pathParams: { datasetId: 'abc' }, query: { format: 'json' }, @@ -252,14 +250,14 @@ describe('read-apify-api', () => { requestMock.mockRejectedValue(new AxiosError('socket hang up', 'ECONNRESET')); await expect( - callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }), + callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }), ).rejects.toThrow('socket hang up'); }); it('describes a binary body instead of returning it', async () => { requestMock.mockResolvedValue(mockResponse(200, Buffer.from([1, 2, 3]), 'application/zip')); - const result = await callTool(readApifyApi, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + const result = await callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); expect(result.structuredContent).toMatchObject({ contentType: 'application/zip', data: null }); diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 5d553125a..195a065e5 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -125,7 +125,7 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => { }); it('should have correct tools in api category (both modes)', () => { - const expected = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_OPERATION_FETCH, HELPER_TOOLS.API_READ]; + const expected = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_DETAILS, HELPER_TOOLS.API_READ]; expect(toolNames(defaultCategories.api)).toEqual(expected); expect(toolNames(appsCategories.api)).toEqual(expected); }); From 496bb6a2b488934eab332ec04cc83756bb95843d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 22:39:54 +0200 Subject: [PATCH 005/103] fix: Keep the token out of API tool errors and tighten the call checks A failed request, such as a network error or a cancelled call, was rethrown as the raw axios error. Its config holds the Authorization header, and the tool error log printed it. The request failure is now rethrown as a plain error with only its message and code. A cancelled call is still reported as cancelled, since the engine reads the signal. apify-api-read also changes in these ways: - It refuses a truthy stream query parameter. On a log operation, stream keeps the request open while the run is running, longer than MCP clients wait for a tool call. - It reads waitForFinish with parseInt, as the API does, so "60s" no longer gets past the 45-second cap. - It swaps username/name to username~name only in resource IDs, not in record keys or other values. - A refusal without a named next step now says that operation IDs are case-sensitive, or that no tool in the session has the access the operation needs. The spec index marks the three metamorph operations unavailable, since a metamorph cannot be undone, and refuses pricingInfos and actorPermissionLevel on actors_post, which the API accepts although the create schema does not declare them. apify-api-search no longer matches "data" for "dataset", drops one-letter terms, and counts a verb such as rename or add for the operation's method. Its description drops the rename example, since no tool in this branch can write. --- src/tools/api/apify_api_read.ts | 12 ++- src/tools/api/apify_api_request.ts | 91 ++++++++++++++------ src/tools/api/apify_api_search.ts | 1 - src/tools/api/apify_api_spec.ts | 44 ++++++++-- tests/unit/helpers/apify_api_spec_fixture.ts | 58 +++++++++++++ tests/unit/tools.apify_api.test.ts | 51 ++++++++--- tests/unit/tools.apify_api_spec.test.ts | 34 ++++++-- 7 files changed, 235 insertions(+), 56 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 138bf822a..8e5ffec55 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -59,7 +59,12 @@ export const apifyApiRead: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); - const resolved = resolveOperationToCall(index, parsed.operationId, API_ACCESS.READ, toolArgs.loadedToolNames); + const resolved = resolveOperationToCall({ + index, + operationId: parsed.operationId, + access: API_ACCESS.READ, + loadedToolNames: toolArgs.loadedToolNames, + }); if ('error' in resolved) return respondUserError(resolved.error); const { operation } = resolved; @@ -68,7 +73,10 @@ export const apifyApiRead: ToolEntry = Object.freeze({ const queryError = validateQueryParams(operation, parsed.query); if (queryError) return respondUserError(queryError); - return callApiOperation(toolArgs.apifyClient, operation, request.path, { + return callApiOperation({ + client: toolArgs.apifyClient, + operation, + path: request.path, query: parsed.query, signal: toolArgs.signal, }); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 7169b65aa..521460e89 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -32,25 +32,22 @@ export const apiCallArgsShape = { ), }; -export type ApiCallParams = { - query?: Record; - /** Aborts the request when the client cancels the tool call. */ - signal?: AbortSignal; -}; - /** The not-found text, naming the search tool only when the session has it. */ export function formatOperationNotFoundMessage(operationId: string, loadedToolNames: readonly string[]): string { - const findIt = loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) ? ` Find it with ${HELPER_TOOLS.API_SEARCH}.` : ''; - return `API operation ${operationId} not found.${findIt}`; + const next = loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) + ? `Find it with ${HELPER_TOOLS.API_SEARCH}.` + : 'Operation IDs are case-sensitive.'; + return `API operation ${operationId} not found. ${next}`; } /** The operation a tool with the given access may call, or the reason it may not. */ -export function resolveOperationToCall( - index: Map, - operationId: string, - access: Exclude, - loadedToolNames: readonly string[], -): { operation: ApiOperation } | { error: string } { +export function resolveOperationToCall(params: { + index: Map; + operationId: string; + access: Exclude; + loadedToolNames: readonly string[]; +}): { operation: ApiOperation } | { error: string } { + const { index, operationId, access, loadedToolNames } = params; const operation = index.get(operationId); if (!operation) return { error: formatOperationNotFoundMessage(operationId, loadedToolNames) }; if (operation.access === API_ACCESS.UNAVAILABLE) { @@ -58,7 +55,9 @@ export function resolveOperationToCall( } if (operation.access !== access) { return { - error: `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ${access} access.`, + error: + `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ` + + `${access} access. No tool in this session has ${operation.access} access.`, }; } return { operation }; @@ -66,6 +65,16 @@ export function resolveOperationToCall( const PATH_PARAMETER_REGEX = /\{([^}]+)\}/g; +/** Path parameters the API also resolves as username~name; other values, such as record keys, are sent as they are. */ +const RESOURCE_ID_PARAMETERS: ReadonlySet = new Set([ + 'actorId', + 'actorTaskId', + 'storeId', + 'datasetId', + 'queueId', + 'scheduleId', +]); + /** Fills the operation's path template; returns the reason instead when the parameters do not fit it. */ export function buildRequestPath( operation: ApiOperation, @@ -84,12 +93,16 @@ export function buildRequestPath( if (!value) return { error: `Missing path parameter ${name}. The path is ${operation.path}.` }; // Encoded, `.` and `..` stay as they are and would move the request to another route. if (value === '.' || value === '..') return { error: `Path parameter ${name} cannot be "${value}".` }; - // The API takes username~name; apify-client swaps the first slash the same way. - path = path.replace(`{${name}}`, encodeURIComponent(value.replace('/', '~'))); + // The API takes username~name; apify-client swaps the first slash of a resource ID the same way. + const apiValue = RESOURCE_ID_PARAMETERS.has(name) ? value.replace('/', '~') : value; + path = path.replace(`{${name}}`, encodeURIComponent(apiValue)); } return { path }; } +/** Query values the API reads as true. */ +const TRUE_QUERY_VALUES: ReadonlySet = new Set(['true', '1']); + /** Checks the query against the parameters the operation declares; returns the reason on failure. */ export function validateQueryParams( operation: ApiOperation, @@ -109,9 +122,17 @@ export function validateQueryParams( .map((parameter) => parameter.name); if (missingNames.length > 0) return `Missing required query parameter ${missingNames.join(', ')}.`; // Same cap as the run and build tools: MCP clients stop waiting for a tool call after 60 seconds. - if (query.waitForFinish !== undefined && Number(query.waitForFinish) > WAIT_SECS_MAX) { + // The API reads the value with parseInt, so "60s" waits 60 seconds. + if (query.waitForFinish !== undefined && Number.parseInt(String(query.waitForFinish), 10) > WAIT_SECS_MAX) { return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. Call the operation again to keep waiting.`; } + // The log operations' stream keeps the request open while the run or build is running. + if (query.stream !== undefined && TRUE_QUERY_VALUES.has(String(query.stream).toLowerCase())) { + return ( + 'stream keeps the request open while the run or build is running, longer than MCP clients wait for ' + + 'a tool call. Call again without stream to get the log so far.' + ); + } return undefined; } @@ -122,21 +143,37 @@ function formatOversizeMessage(operation: ApiOperation, path: string): string { ); } +/** + * The request failure as a plain error with its message and code. The axios error keeps the request + * config, which holds the Authorization header and the request body, and the tool error log prints + * the whole error. The engine tells a cancelled call by its signal, not by the error type. + */ +function toPlainRequestError(error: unknown): Error { + if (!(error instanceof Error)) return new Error(String(error)); + const { code } = error as { code?: unknown }; + return Object.assign(new Error(error.message), typeof code === 'string' ? { code } : {}); +} + /** * Sends one request to a filled-in operation path and returns the response body. * * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one * attempt and no retries, since a retried write could apply twice, and apify-client would retry the * `maxContentLength` abort as a network error. The instance still adds the token and the request-origin - * and payment headers, and parses JSON and text bodies. A non-2xx response is thrown as the - * `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. + * and payment headers, and parses JSON and text bodies. Like `readApiResource`, the request skips the + * setup `httpClient.call()` runs first, so it does not honor `HTTPS_PROXY` and goes out with axios's + * default User-Agent instead of apify-client's. A non-2xx response is thrown as the `ApifyApiError` + * apify-client itself builds, so it gets the usual tool error text and telemetry. */ -export async function callApiOperation( - client: ApifyClient, - operation: ApiOperation, - path: string, - params: ApiCallParams, -): Promise { +export async function callApiOperation(params: { + client: ApifyClient; + operation: ApiOperation; + path: string; + query?: Record; + /** Aborts the request when the client cancels the tool call. */ + signal?: AbortSignal; +}): Promise { + const { client, operation, path } = params; let response: AxiosResponse; try { response = await client.httpClient.axios.request({ @@ -149,7 +186,7 @@ export async function callApiOperation( }); } catch (error) { if (isMaxContentLengthAbort(error)) return respondUserError(formatOversizeMessage(operation, path)); - throw error; + throw toPlainRequestError(error); } if (response.status >= 300) throw new ApifyApiError(response, 1); diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 6b4f414e1..a9fa74cc7 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -41,7 +41,6 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { USAGE EXAMPLES: - user_input: List the webhooks on my account - - user_input: Rename my dataset to leads-2026 - user_input: How much of my monthly usage have I spent? `; } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 31c266304..81e7d87da 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -47,7 +47,9 @@ const DELETE_REASON = 'Deletion cannot be undone, so the API tools do not delete. The user can delete it in Apify Console.'; const SYNC_RUN_REASON = 'It waits up to 300 seconds for the run to finish, longer than MCP clients wait for a tool call. ' + - 'Start the run with the asynchronous run operation instead.'; + 'Start the run with actors_runs_post or actorTask_runs_post instead.'; +const METAMORPH_REASON = + 'It turns the run into a run of another Actor, which cannot be undone. Only the Actor itself metamorphs its run.'; /** Synchronous run paths. Some are GET, but every one of them starts a paid run. */ const SYNC_RUN_PATH_REGEX = /\/run-sync(-get-dataset-items)?$/; @@ -56,6 +58,9 @@ const SYNC_RUN_PATH_REGEX = /\/run-sync(-get-dataset-items)?$/; const UNAVAILABLE_OPERATION_REASONS: ReadonlyMap = new Map([ ['users_me_limits_put', "It changes the account's spending limits. The user can change them in Apify Console."], ['PostChargeRun', 'It charges the user of a pay-per-event run. Only the Actor itself charges for its events.'], + ['actorRun_metamorph_post', METAMORPH_REASON], + ['actor_runs_last_metamorph_post', METAMORPH_REASON], + ['actorTask_runs_last_metamorph_post', METAMORPH_REASON], ]); /** @@ -70,7 +75,12 @@ export const REFUSED_BODY_FIELDS: ReadonlySet = new Set([ 'generalAccess', ]); -/** Schema keywords that only cost context: examples and the docs' vendor extensions (`x-*`). */ +/** Refused fields the API accepts on an operation although its body schema does not declare them. */ +const UNDECLARED_REFUSED_BODY_FIELDS: ReadonlyMap = new Map([ + ['actors_post', ['pricingInfos', 'actorPermissionLevel']], +]); + +/** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); const openApiOperationValidator = z.object({ @@ -207,6 +217,7 @@ export function buildApiOperationIndex(spec: unknown): Map const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); const declaredBodyFields = extractTopLevelPropertyNames(body?.schema); + const undeclaredRefusedFields = UNDECLARED_REFUSED_BODY_FIELDS.get(operationId) ?? []; index.set(operationId, { operationId, method, @@ -216,7 +227,9 @@ export function buildApiOperationIndex(spec: unknown): Map tags: tags ?? [], parameters: parseParameters(parameters, spec), ...(body && { requestBody: body }), - refusedBodyFields: [...REFUSED_BODY_FIELDS].filter((field) => declaredBodyFields.has(field)), + refusedBodyFields: [...REFUSED_BODY_FIELDS].filter( + (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), + ), ...resolveAccess(method, path, operationId), }); } @@ -274,30 +287,43 @@ function splitWords(text: string): string[] { .filter(Boolean); } -/** A prefix match either way, so `runs` finds `run` and `env` finds `environment`. */ +/** Verbs for what each method does, so a verb the summary does not use, such as rename, still counts. */ +const METHOD_VERBS: Record = { + GET: ['get', 'list', 'read'], + POST: ['add', 'create', 'push', 'start'], + PUT: ['update', 'set', 'rename', 'change'], + DELETE: ['delete', 'remove'], +}; + +/** A prefix match, or the plural of a word, so `env` finds `environment` and `runs` finds `run`. */ function hasMatchingWord(term: string, words: string[]): boolean { - return words.some((word) => word.startsWith(term) || (word.length >= 3 && term.startsWith(word))); + return words.some((word) => word.startsWith(term) || term === `${word}s`); } /** * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the - * operation ID or path, else 1 in the tags. Ties go to the shorter path, so `/v2/datasets/{datasetId}` - * comes before the same operation on a run's default dataset. + * operation ID or path, else 1 in the tags, else 2 when it is a verb for the operation's method. A verb + * alone does not match an operation. Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes + * before the same operation on a run's default dataset. */ export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { - const terms = [...new Set(splitWords(query))].filter((term) => !SEARCH_STOP_WORDS.has(term)); + // One-letter terms, such as the s of "run's", match too many words. + const terms = [...new Set(splitWords(query))].filter((term) => term.length > 1 && !SEARCH_STOP_WORDS.has(term)); const scored: { operation: ApiOperation; score: number }[] = []; for (const operation of index.values()) { const summaryWords = splitWords(operation.summary); const idWords = splitWords(`${operation.operationId} ${operation.path}`); const tagWords = splitWords(operation.tags.join(' ')); + const verbs = METHOD_VERBS[operation.method]; let score = 0; + let verbScore = 0; for (const term of terms) { if (hasMatchingWord(term, summaryWords)) score += 3; else if (hasMatchingWord(term, idWords)) score += 2; else if (hasMatchingWord(term, tagWords)) score += 1; + else if (verbs.includes(term)) verbScore += 2; } - if (score > 0) scored.push({ operation, score }); + if (score > 0) scored.push({ operation, score: score + verbScore }); } return scored .sort((a, b) => b.score - a.score || a.operation.path.length - b.operation.path.length) diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 4eb738b50..2db1ac261 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -47,6 +47,13 @@ export const API_SPEC_FIXTURE = { { name: 'Accept-Encoding', in: 'header', schema: { type: 'string' } }, ], }, + post: { + operationId: 'dataset_items_post', + summary: 'Store items', + tags: ['Storage/Datasets'], + parameters: [{ $ref: '#/components/parameters/datasetId' }], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, + }, head: { operationId: 'dataset_items_head', summary: 'Get dataset items headers' }, }, '/v2/actor-runs/{runId}/dataset': { @@ -71,6 +78,33 @@ export const API_SPEC_FIXTURE = { ], }, }, + '/v2/actor-runs/{runId}/log': { + get: { + operationId: 'actorRun_log_get', + summary: 'Get log', + tags: ['Logs'], + parameters: [ + { name: 'runId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'stream', in: 'query', schema: { type: 'boolean' } }, + ], + }, + }, + '/v2/actor-runs/{runId}/metamorph': { + post: { + operationId: 'actorRun_metamorph_post', + summary: 'Metamorph run', + tags: ['Actor runs'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, + '/v2/actor-runs/{runId}/charge': { + post: { + operationId: 'PostChargeRun', + summary: 'Charge events in run', + tags: ['Actor runs'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, '/v2/actor-runs/{runId}/abort': { post: { operationId: 'actorRun_abort_post', @@ -79,6 +113,17 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], }, }, + '/v2/actors': { + post: { + operationId: 'actors_post', + summary: 'Create Actor', + tags: ['Actors'], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateActorRequest' } } }, + }, + }, + }, '/v2/actors/{actorId}': { put: { operationId: 'actor_put', @@ -106,6 +151,14 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, + '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items': { + get: { + operationId: 'actorTask_runSyncGetDatasetItems_get', + summary: 'Run task synchronously and get dataset items', + tags: ['Actor tasks'], + parameters: [{ name: 'actorTaskId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, '/v2/key-value-stores/{storeId}/records/{recordKey}': { put: { operationId: 'keyValueStore_record_put', @@ -169,6 +222,11 @@ export const API_SPEC_FIXTURE = { example: { type: 'string', description: 'A property that happens to be named example.' }, }, }, + // Like the published spec, it does not declare pricingInfos or actorPermissionLevel. + CreateActorRequest: { + type: 'object', + properties: { name: { type: 'string' }, isPublic: { type: 'boolean' } }, + }, UpdateActorRequest: { allOf: [{ $ref: '#/components/schemas/ActorPermissionFields' }], properties: { diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 33c03e8ef..0d04255eb 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -1,5 +1,5 @@ import { ApifyApiError } from 'apify-client'; -import { AxiosError } from 'axios'; +import { AxiosError, AxiosHeaders } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; @@ -69,6 +69,12 @@ describe('buildRequestPath()', () => { expect(buildRequestPath(INDEX.get('webhooks_get')!)).toEqual({ path: '/v2/webhooks' }); }); + it('sends a value that is not a resource ID, such as a record key, as it is', () => { + expect( + buildRequestPath(INDEX.get('keyValueStore_record_put')!, { storeId: 'john/store', recordKey: 'a/b' }), + ).toEqual({ path: '/v2/key-value-stores/john~store/records/a%2Fb' }); + }); + it.each([ [{}, 'Missing path parameter datasetId'], [{ datasetId: '' }, 'Missing path parameter datasetId'], @@ -98,9 +104,21 @@ describe('validateQueryParams()', () => { ); }); - it('caps waitForFinish below the tool-call timeout', () => { - expect(validateQueryParams(INDEX.get('actorRun_get')!, { waitForFinish: 45 })).toBeUndefined(); - expect(validateQueryParams(INDEX.get('actorRun_get')!, { waitForFinish: 60 })).toContain('at most 45 seconds'); + it('caps waitForFinish below the tool-call timeout, reading it as the API does', () => { + const actorRunGet = INDEX.get('actorRun_get')!; + expect(validateQueryParams(actorRunGet, { waitForFinish: 45 })).toBeUndefined(); + expect(validateQueryParams(actorRunGet, { waitForFinish: 60 })).toContain('at most 45 seconds'); + expect(validateQueryParams(actorRunGet, { waitForFinish: '60s' })).toContain('at most 45 seconds'); + }); + + it.each([true, 'true', '1', 1])('refuses stream %j, which keeps the log request open', (stream) => { + expect(validateQueryParams(INDEX.get('actorRun_log_get')!, { stream })).toContain( + 'Call again without stream to get the log so far.', + ); + }); + + it('accepts a false stream', () => { + expect(validateQueryParams(INDEX.get('actorRun_log_get')!, { stream: false })).toBeUndefined(); }); }); @@ -155,7 +173,7 @@ describe('apify-api-details', () => { expect(withSearch.content[0].text).toBe( `API operation nope not found. Find it with ${HELPER_TOOLS.API_SEARCH}.`, ); - expect(withoutSearch.content[0].text).toBe('API operation nope not found.'); + expect(withoutSearch.content[0].text).toBe('API operation nope not found. Operation IDs are case-sensitive.'); }); }); @@ -191,7 +209,11 @@ describe('apify-api-read', () => { }); it.each([ - ['dataset_put', 'dataset_put is a PUT operation with write access; this tool has read access.'], + [ + 'dataset_put', + 'dataset_put is a PUT operation with write access; this tool has read access. ' + + 'No tool in this session has write access.', + ], ['actor_runSync_get', 'The API tools do not call actor_runSync_get. It waits up to 300 seconds'], ['nope', 'API operation nope not found.'], ])('refuses %s without a request', async (operationId, reason) => { @@ -246,12 +268,19 @@ describe('apify-api-read', () => { expect(result.content[0].text).toContain('limit, offset, or fields'); }); - it('rethrows any other request failure', async () => { - requestMock.mockRejectedValue(new AxiosError('socket hang up', 'ECONNRESET')); + it('rethrows any other request failure without the request config, which holds the token', async () => { + const config = { headers: new AxiosHeaders({ Authorization: 'Bearer secret-token' }) }; + requestMock.mockRejectedValue(new AxiosError('socket hang up', 'ECONNRESET', config)); + + const error = await callTool(apifyApiRead, { + operationId: 'dataset_get', + pathParams: { datasetId: 'abc' }, + }).catch((thrown: unknown) => thrown); - await expect( - callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }), - ).rejects.toThrow('socket hang up'); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(AxiosError); + expect(error).toMatchObject({ message: 'socket hang up', code: 'ECONNRESET' }); + expect(JSON.stringify(error, Object.getOwnPropertyNames(error))).not.toContain('secret-token'); }); it('describes a binary body instead of returning it', async () => { diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index a996d26cb..0d278be21 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(13); + expect(index.size).toBe(19); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -53,7 +53,10 @@ describe('buildApiOperationIndex()', () => { it.each([ ['dataset_delete', 'Deletion cannot be undone'], ['actor_runSync_get', 'waits up to 300 seconds'], + ['actorTask_runSyncGetDatasetItems_get', 'waits up to 300 seconds'], ['users_me_limits_put', 'spending limits'], + ['PostChargeRun', 'Only the Actor itself charges'], + ['actorRun_metamorph_post', 'cannot be undone'], ])('makes %s unavailable with the reason', (operationId, reason) => { const operation = index.get(operationId); expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); @@ -63,6 +66,12 @@ describe('buildApiOperationIndex()', () => { it('refuses publishing, pricing, permission and sharing fields only where the body schema declares them', () => { expect(index.get('actor_put')?.refusedBodyFields).toEqual(['isPublic', 'pricingInfos', 'actorPermissionLevel']); expect(index.get('dataset_put')?.refusedBodyFields).toEqual(['generalAccess']); + // The API takes these on create, although the create schema does not declare them. + expect(index.get('actors_post')?.refusedBodyFields).toEqual([ + 'isPublic', + 'pricingInfos', + 'actorPermissionLevel', + ]); // A record body is free-form: a record may hold an isPublic key as plain data. expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); }); @@ -81,11 +90,24 @@ describe('searchApiOperations()', () => { expect(ids.slice(0, 2)).toEqual(['dataset_put', 'actorRun_dataset_put']); }); - it('matches word forms either way and ignores stop words', () => { - const ids = searchApiOperations(index, 'list all of the webhooks', 10).map( - (operation) => operation.operationId, - ); - expect(ids).toEqual(['webhooks_get', 'actor_webhooks_get']); + function searchIds(query: string): string[] { + return searchApiOperations(index, query, 10).map((operation) => operation.operationId); + } + + it('matches word prefixes and ignores stop words', () => { + // Without the stop words, "with" would also match "Run Actor synchronously without input". + expect(searchIds('webhook with')).toEqual(['webhooks_get', 'actor_webhooks_get']); + }); + + it('matches a plural to its singular and drops one-letter terms', () => { + expect(searchIds('abort runs')[0]).toBe('actorRun_abort_post'); + expect(searchIds("dataset's")).toEqual(searchIds('dataset')); + }); + + it("counts a verb for the operation's method, but not on its own", () => { + expect(searchIds('rename dataset')[0]).toBe('dataset_put'); + expect(searchIds('add items to a dataset')[0]).toBe('dataset_items_post'); + expect(searchIds('rename')).toEqual([]); }); it('caps the results at the limit', () => { From f8fcd565313e418eab26d74b5b4286f38d71194e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 22:42:00 +0200 Subject: [PATCH 006/103] refactor: Rename write-apify-api to apify-api-write It joins the apify-api-* family the other API tools moved to. The tool file is now apify_api_write.ts and the constant apifyApiWrite. The tool is not released yet, so nothing else changes. --- README.md | 2 +- src/const.ts | 2 +- src/tools/AGENTS.md | 2 +- .../{write_apify_api.ts => apify_api_write.ts} | 10 +++++----- src/tools/registry.ts | 4 ++-- src/tools/structured_output_schemas.ts | 2 +- tests/unit/tools.apify_api.test.ts | 16 ++++++++-------- 7 files changed, 19 insertions(+), 19 deletions(-) rename src/tools/api/{write_apify_api.ts => apify_api_write.ts} (93%) diff --git a/README.md b/README.md index 2b580fc25..ac57aa2d8 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Legend for the **Enabled by default** column: | `apify-api-search` | api | Search the Apify API reference for operations by keywords. | | | `apify-api-details` | api | Get an Apify API operation's parameters and request body schema. | | | `apify-api-read` | api | Call a read-only (GET) Apify API operation by its operation ID. | | -| `write-apify-api` | api | Call a POST or PUT Apify API operation by its operation ID. Deletions, publishing, and pricing changes are refused. | | +| `apify-api-write` | api | Call a POST or PUT Apify API operation by its operation ID. Deletions, publishing, and pricing changes are refused. | | > **Note:** > diff --git a/src/const.ts b/src/const.ts index 01e4fc569..a94d3022b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -76,7 +76,7 @@ export const HELPER_TOOLS = { API_SEARCH: 'apify-api-search', API_DETAILS: 'apify-api-details', API_READ: 'apify-api-read', - API_WRITE: 'write-apify-api', + API_WRITE: 'apify-api-write', PROBLEM_REPORT: 'report-problem', } as const; export type HelperToolName = (typeof HELPER_TOOLS)[keyof typeof HELPER_TOOLS]; diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index a5ed2510d..076d9b15e 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -37,7 +37,7 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. the operation index from the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason, plus the body fields the write tool refuses. `apify_api_request.ts` fills in the path, checks the query and body, and sends one request with no - retries. `write-apify-api` redacts its `body` in logs through `redactArgs`. + retries. `apify-api-write` redacts its `body` in logs through `redactArgs`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/write_apify_api.ts b/src/tools/api/apify_api_write.ts similarity index 93% rename from src/tools/api/write_apify_api.ts rename to src/tools/api/apify_api_write.ts index 2dd695369..ce311595f 100644 --- a/src/tools/api/write_apify_api.ts +++ b/src/tools/api/apify_api_write.ts @@ -17,7 +17,7 @@ import { } from './apify_api_request.js'; import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; -const writeApifyApiArgs = z.object({ +const apifyApiWriteArgs = z.object({ ...apiCallArgsShape, body: z .unknown() @@ -50,15 +50,15 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * Calls one POST or PUT operation of the published Apify API spec, https://docs.apify.com/api/v2. */ -export const writeApifyApi: ToolEntry = Object.freeze({ +export const apifyApiWrite: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, name: HELPER_TOOLS.API_WRITE, title: 'Write Apify API', description: buildDescription(ALL_TOOLS_PRESENT), buildDescription, - inputSchema: z.toJSONSchema(writeApifyApiArgs) as ToolInputSchema, + inputSchema: z.toJSONSchema(apifyApiWriteArgs) as ToolInputSchema, outputSchema: apifyApiCallOutputSchema, - ajvValidate: compileSchema(z.toJSONSchema(writeApifyApiArgs)), + ajvValidate: compileSchema(z.toJSONSchema(apifyApiWriteArgs)), annotations: { title: 'Write Apify API', readOnlyHint: false, @@ -69,7 +69,7 @@ export const writeApifyApi: ToolEntry = Object.freeze({ // A body can carry secrets: environment variable values, webhook headers, stored records. redactArgs: (args: Record) => ('body' in args ? { ...args, body: '[REDACTED]' } : args), call: async (toolArgs: InternalToolArgs) => { - const parsed = writeApifyApiArgs.parse(toolArgs.args); + const parsed = apifyApiWriteArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); const resolved = resolveOperationToCall({ index, diff --git a/src/tools/registry.ts b/src/tools/registry.ts index bd356d3de..e1108f335 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -23,7 +23,7 @@ import { searchActors } from './actors/search_actors.js'; import { apifyApiDetails } from './api/apify_api_details.js'; import { apifyApiRead } from './api/apify_api_read.js'; import { apifyApiSearch } from './api/apify_api_search.js'; -import { writeApifyApi } from './api/write_apify_api.js'; +import { apifyApiWrite } from './api/apify_api_write.js'; import { buildActor } from './builds/build_actor.js'; import { getActorBuild } from './builds/get_actor_build.js'; import { getActorBuildLog } from './builds/get_actor_build_log.js'; @@ -74,7 +74,7 @@ export const toolCategories = { tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask], schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, buildActor], - api: [apifyApiSearch, apifyApiDetails, apifyApiRead, writeApifyApi], + api: [apifyApiSearch, apifyApiDetails, apifyApiRead, apifyApiWrite], dev: [reportProblem], } satisfies Record; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 28d51d443..351e259af 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -960,7 +960,7 @@ export const apifyApiDetailsOutputSchema = { }; /** - * Schema for the response of an API call (apify-api-read, write-apify-api). + * Schema for the response of an API call (apify-api-read, apify-api-write). */ export const apifyApiCallOutputSchema = { type: 'object' as const, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index c01095708..24d0a2e6d 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -9,7 +9,7 @@ import { buildRequestPath, validateQueryParams } from '../../src/tools/api/apify import { apifyApiSearch } from '../../src/tools/api/apify_api_search.js'; import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; -import { writeApifyApi } from '../../src/tools/api/write_apify_api.js'; +import { apifyApiWrite } from '../../src/tools/api/apify_api_write.js'; import { apifyApiCallOutputSchema, apifyApiDetailsOutputSchema, @@ -307,12 +307,12 @@ describe('apify-api-read', () => { }); }); -describe('write-apify-api', () => { +describe('apify-api-write', () => { it('sends one request with the JSON body and returns the response', async () => { const body = { data: { id: 'abc', name: 'leads-2026' } }; requestMock.mockResolvedValue(mockResponse(200, body)); - const result = await callTool(writeApifyApi, { + const result = await callTool(apifyApiWrite, { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, body: { name: 'leads-2026' }, @@ -334,7 +334,7 @@ describe('write-apify-api', () => { it('sends an operation without a body when none is given', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { id: 'run-1', status: 'ABORTING' } })); - await callTool(writeApifyApi, { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' } }); + await callTool(apifyApiWrite, { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' } }); expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', data: undefined })); }); @@ -368,7 +368,7 @@ describe('write-apify-api', () => { 'The API tools do not set generalAccess', ], ])('refuses %j without a request', async (args, reason) => { - const result = await callTool(writeApifyApi, args); + const result = await callTool(apifyApiWrite, args); expectSoftFailInvalidInput(result); expect(result.content[0].text).toContain(reason); @@ -378,7 +378,7 @@ describe('write-apify-api', () => { it('sends a refused field name when the body is free-form, such as a stored record', async () => { requestMock.mockResolvedValue(mockResponse(201, undefined, '')); - const result = await callTool(writeApifyApi, { + const result = await callTool(apifyApiWrite, { operationId: 'keyValueStore_record_put', pathParams: { storeId: 'store-1', recordKey: 'CONFIG' }, body: { isPublic: true }, @@ -393,7 +393,7 @@ describe('write-apify-api', () => { new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), ); - const result = await callTool(writeApifyApi, { + const result = await callTool(apifyApiWrite, { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, body: { name: 'leads-2026' }, @@ -403,7 +403,7 @@ describe('write-apify-api', () => { }); describe('redactArgs()', () => { - const { redactArgs } = writeApifyApi as HelperTool; + const { redactArgs } = apifyApiWrite as HelperTool; it('redacts the body in the logged copy without changing the arguments', () => { const args = { operationId: 'actor_version_envVar_put', body: { value: 'secret' } }; From b697f5ff7ae01f0c88e5f4046213fd9e08950ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 22:47:08 +0200 Subject: [PATCH 007/103] fix: Send apify-api-write bodies as JSON and keep secrets out of its logs The body went to axios as given, and axios serializes only objects and arrays as JSON. A body the agent had JSON-encoded itself went out as a form, so a rename returned HTTP 200 and changed nothing, and a number threw before the request. The body is now serialized with JSON.stringify and sent with Content-Type application/json. A string or null body is refused with the reason: a JSON-looking string would otherwise skip the refused-field check. redactArgs is now an allowlist of the declared arguments, as the hook's docs now require, so a secret passed under another key is not logged. It also redacts a webhooks query parameter, whose webhooks carry headers. The no-provider path, which token sessions use, now has its own test. Other changes: - forcePermissionLevel is refused, like actorPermissionLevel in a body. - A waitForFinish over the cap on a run or build POST now says to wait with the run's or build's GET operation, since calling the POST again starts another run. - A write whose response is over 256 KB is reported as done, with its status code, instead of as a failed call, since it has already applied. - apify-api-search shows the rename example only when apify-api-write is loaded. --- src/payments/AGENTS.md | 5 +- src/tools/AGENTS.md | 3 +- src/tools/api/apify_api_request.ts | 56 +++++++- src/tools/api/apify_api_search.ts | 3 +- src/tools/api/apify_api_write.ts | 25 ++-- src/types.ts | 3 +- tests/unit/helpers/apify_api_spec_fixture.ts | 14 ++ tests/unit/payments.helpers.test.ts | 13 ++ tests/unit/tools.apify_api.test.ts | 137 +++++++++++++++++-- tests/unit/tools.apify_api_spec.test.ts | 2 +- 10 files changed, 232 insertions(+), 29 deletions(-) diff --git a/src/payments/AGENTS.md b/src/payments/AGENTS.md index 4301c5cea..4fbebbbf2 100644 --- a/src/payments/AGENTS.md +++ b/src/payments/AGENTS.md @@ -40,8 +40,9 @@ The cross-file invariant no single file shows you: asserts against; keep it in sync with the `paymentRequired` tools or that test fails. - `skyfire-pay-id` is injected only at the top level, so `redactSkyfirePayId` redacts top-level only; if you ever nest a payment field, make the redactor recursive or it leaks. - A tool whose arguments carry secrets sets `redactArgs` on its entry (`HelperTool` in `../types.ts`) - instead of adding a case here. It runs before AJV validation, so it must accept any shape and must - not mutate the arguments; `cloneToolEntry` keeps it on the copies the providers decorate. + instead of adding a case here. It runs before AJV validation, so it must accept any shape, must + not mutate the arguments, and must be an allowlist: the logged copy keeps the undeclared keys AJV + strips only from the tool's copy. `cloneToolEntry` keeps it on the copies the providers decorate. ## Local commands diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 076d9b15e..090a873f9 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -37,7 +37,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. the operation index from the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason, plus the body fields the write tool refuses. `apify_api_request.ts` fills in the path, checks the query and body, and sends one request with no - retries. `apify-api-write` redacts its `body` in logs through `redactArgs`. + retries. `apify-api-write` logs only its declared arguments, with `body` and a `webhooks` query + parameter redacted, through `redactArgs`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 0b5ec01fd..bf19b807d 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -1,5 +1,6 @@ import { ApifyApiError } from 'apify-client'; import type { AxiosResponse } from 'axios'; +import { isAxiosError } from 'axios'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; @@ -128,7 +129,20 @@ export function validateQueryParams( // Same cap as the run and build tools: MCP clients stop waiting for a tool call after 60 seconds. // The API reads the value with parseInt, so "60s" waits 60 seconds. if (query.waitForFinish !== undefined && Number.parseInt(String(query.waitForFinish), 10) > WAIT_SECS_MAX) { - return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. Call the operation again to keep waiting.`; + // Calling a run or build POST again starts another paid run or build. + const next = + operation.method === 'GET' + ? 'Call the operation again to keep waiting.' + : "To keep waiting after that, call the run's or build's GET operation with waitForFinish; " + + 'calling this operation again starts another one.'; + return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. ${next}`; + } + // It sets the run's permission level, like the refused actorPermissionLevel body field. + if (query.forcePermissionLevel !== undefined) { + return ( + 'The API tools do not set forcePermissionLevel, whatever the value: permission changes need a ' + + 'dedicated tool or Apify Console. Remove the parameter and call again.' + ); } // The log operations' stream keeps the request open while the run or build is running. if (query.stream !== undefined && TRUE_QUERY_VALUES.has(String(query.stream).toLowerCase())) { @@ -148,6 +162,13 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str if (body === undefined) { return operation.requestBody.isRequired ? `${operation.operationId} needs a request body.` : undefined; } + // A JSON-encoded string would reach the API as a string, not as the object, and skip the checks below. + if (typeof body === 'string' || body === null) { + return ( + `Pass the body of ${operation.operationId} as a JSON object or array, not as ` + + `${body === null ? 'null' : 'a string'}.` + ); + } if (!isRecord(body)) return undefined; const refusedFields = operation.refusedBodyFields.filter((field) => field in body); if (refusedFields.length > 0) { @@ -167,6 +188,13 @@ function formatOversizeMessage(operation: ApiOperation, path: string): string { return `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ${next}`; } +/** The status of a response axios aborted mid-body; Node's request object keeps the response it got. */ +function readAbortedResponseStatus(error: unknown): number | undefined { + const request: unknown = isAxiosError(error) ? error.request : undefined; + const statusCode = isRecord(request) && isRecord(request.res) ? request.res.statusCode : undefined; + return typeof statusCode === 'number' ? statusCode : undefined; +} + /** * The request failure as a plain error with its message and code. The axios error keeps the request * config, which holds the Authorization header and the request body, and the tool error log prints @@ -207,12 +235,34 @@ export async function callApiOperation(params: { url: `${client.baseUrl}${path.slice('/v2'.length)}`, method: operation.method, params: params.query, - data: params.body, + // Serialized here: axios would send a string as a form and refuse a number. With only the JSON + // header, a JSON-looking string would reach the API as the object it holds. + ...(params.body !== undefined && { + data: JSON.stringify(params.body), + headers: { 'Content-Type': 'application/json' }, + }), maxContentLength: MAX_INLINE_BYTES, signal: params.signal, }); } catch (error) { - if (isMaxContentLengthAbort(error)) return respondUserError(formatOversizeMessage(operation, path)); + if (isMaxContentLengthAbort(error)) { + // A write has applied by the time its response arrives, so a large response is not a failure. + const statusCode = readAbortedResponseStatus(error); + if (operation.method !== 'GET' && statusCode !== undefined && statusCode < 300) { + const structuredContent = { + operationId: operation.operationId, + method: operation.method, + path, + statusCode, + data: null, + }; + const summary = + `${operation.method} ${path} returned HTTP ${statusCode}. The response is larger than ` + + `${MAX_INLINE_BYTES} bytes, so it is not returned; check the result with an operation with read access.`; + return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); + } + return respondUserError(formatOversizeMessage(operation, path)); + } throw toPlainRequestError(error); } if (response.status >= 300) throw new ApifyApiError(response, 1); diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 3e06589ac..ed917f774 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -32,6 +32,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS}`, callTools.length > 0 && `call it with ${callTools.join(' or ')}`, ].filter(Boolean); + const renameExample = hasTool(HELPER_TOOLS.API_WRITE) ? '\n- user_input: Rename my dataset to leads-2026' : ''; const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; return dedent` Search the Apify API reference for operations by keywords. Returns each operation's ID, method, @@ -43,7 +44,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { - Tell the user the reason when the operation they need is unavailable. USAGE EXAMPLES: - - user_input: List the webhooks on my account + - user_input: List the webhooks on my account${renameExample} - user_input: How much of my monthly usage have I spent? `; } diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index ce311595f..e15e3099f 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -15,14 +15,17 @@ import { validateQueryParams, validateRequestBody, } from './apify_api_request.js'; -import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; +import { API_ACCESS, fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; const apifyApiWriteArgs = z.object({ ...apiCallArgsShape, body: z .unknown() .optional() - .describe('The request body, sent as JSON. Required when the operation needs one; omit it otherwise.'), + .describe( + 'The request body as a JSON object or array, not a JSON-encoded string. It is sent as JSON. ' + + 'Required when the operation needs one; omit it otherwise.', + ), }); function buildDescription({ hasTool }: ToolDescriptionContext): string { @@ -32,10 +35,10 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { return dedent` Call one Apify API operation with write access (POST or PUT), by its operation ID. The server builds the URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} - The request is sent once and applies at once. Refused: deletions, synchronous runs, spending limits, - run charging, and body fields that publish an Actor or task, change its pricing or permissions, or - change who can read a storage. Returns the response body as the API sends it; a body over - ${MAX_INLINE_BYTES} bytes is not returned. + The request is sent once and applies at once. Refused: deletions, synchronous runs, metamorphs, + spending limits, run charging, and fields that publish an Actor or task, change its pricing or + permissions, or change who can read a storage. Returns the response body as the API sends it; a + body over ${MAX_INLINE_BYTES} bytes is not returned. USAGE: - Use to change something no dedicated tool changes, such as a dataset's name or a webhook. @@ -66,8 +69,14 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ idempotentHint: false, openWorldHint: false, }, - // A body can carry secrets: environment variable values, webhook headers, stored records. - redactArgs: (args: Record) => ('body' in args ? { ...args, body: '[REDACTED]' } : args), + // A body can carry secrets: environment variable values, webhook headers, stored records. So can the + // webhooks query parameter of a run, whose webhooks carry headers. Undeclared keys are left out. + redactArgs: ({ operationId, pathParams, query, body }: Record) => ({ + operationId, + pathParams, + query: isRecord(query) && 'webhooks' in query ? { ...query, webhooks: '[REDACTED]' } : query, + ...(body !== undefined && { body: '[REDACTED]' }), + }), call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); diff --git a/src/types.ts b/src/types.ts index a886e8f49..e0f563c29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,7 +208,8 @@ export type HelperTool = ToolBase & { /** * Returns the copy of the call's arguments that is logged, for a tool whose arguments carry * secrets. Applied in `prepareToolCallContext`, so every log of the arguments sees it. It runs - * before AJV validation: it must accept arguments of any shape and must not mutate them. + * before AJV validation: it must accept arguments of any shape and must not mutate them, and it + * still sees the undeclared keys AJV strips later, so build the logged copy as an allowlist. */ redactArgs?: (args: Record) => Record; }; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 2db1ac261..b11a362b6 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -135,6 +135,20 @@ export const API_SPEC_FIXTURE = { }, }, }, + '/v2/actors/{actorId}/runs': { + post: { + operationId: 'actors_runs_post', + summary: 'Run Actor', + tags: ['Actors/Actor runs'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'waitForFinish', in: 'query', schema: { type: 'number' } }, + { name: 'webhooks', in: 'query', schema: { type: 'string' } }, + { name: 'forcePermissionLevel', in: 'query', schema: { type: 'string' } }, + ], + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + }, + }, '/v2/actors/{actorId}/runs/{runId}': { get: { operationId: 'actors_run_get', diff --git a/tests/unit/payments.helpers.test.ts b/tests/unit/payments.helpers.test.ts index 402723555..a63b8045c 100644 --- a/tests/unit/payments.helpers.test.ts +++ b/tests/unit/payments.helpers.test.ts @@ -94,6 +94,19 @@ describe('prepareToolCallContext — no provider', () => { expect(result.toolArgsWithoutPayment).toEqual(args); }); + it("applies the tool's redactArgs to logSafeArgs", () => { + const args = { actor: 'apify/rag-web-browser', secret: 'hide-me' }; + const tool = { + ...makeTool(false), + redactArgs: (toolArgs: Record) => ({ ...toolArgs, secret: '[REDACTED]' }), + }; + const result = prepareToolCallContext({ provider: undefined, tool, args, apifyToken: MOCK_APIFY_TOKEN }); + + expect(result.toolArgsRedacted).toEqual({ actor: 'apify/rag-web-browser', secret: '[REDACTED]' }); + // The tool still gets the real value. + expect(result.toolArgsWithoutPayment).toEqual({ actor: 'apify/rag-web-browser', secret: 'hide-me' }); + }); + it('AJV mutation of toolArgs does not affect logSafeArgs', () => { const args = { actor: 'apify/rag-web-browser', unknownExtra: 'should-be-stripped' }; const result = prepareToolCallContext({ diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 24d0a2e6d..851e1f014 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -2,6 +2,7 @@ import { ApifyApiError } from 'apify-client'; import { AxiosError, AxiosHeaders } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApifyClient } from '../../src/apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; @@ -308,7 +309,9 @@ describe('apify-api-read', () => { }); describe('apify-api-write', () => { - it('sends one request with the JSON body and returns the response', async () => { + const REQUEST_BASE = { params: undefined, maxContentLength: MAX_INLINE_BYTES, signal: expect.any(AbortSignal) }; + + it('sends one request with the body serialized as JSON and returns the response', async () => { const body = { data: { id: 'abc', name: 'leads-2026' } }; requestMock.mockResolvedValue(mockResponse(200, body)); @@ -320,12 +323,11 @@ describe('apify-api-write', () => { expect(requestMock).toHaveBeenCalledTimes(1); expect(requestMock).toHaveBeenCalledWith({ + ...REQUEST_BASE, url: `${BASE_URL}/datasets/abc`, method: 'PUT', - params: undefined, - data: { name: 'leads-2026' }, - maxContentLength: MAX_INLINE_BYTES, - signal: expect.any(AbortSignal), + data: '{"name":"leads-2026"}', + headers: { 'Content-Type': 'application/json' }, }); expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); expect(result.structuredContent).toMatchObject({ method: 'PUT', path: '/v2/datasets/abc', data: body }); @@ -336,17 +338,65 @@ describe('apify-api-write', () => { await callTool(apifyApiWrite, { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' } }); - expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', data: undefined })); + expect(requestMock).toHaveBeenCalledWith({ + ...REQUEST_BASE, + url: `${BASE_URL}/actor-runs/run-1/abort`, + method: 'POST', + }); + }); + + it('sends the body as application/json through the real apify-client axios instance', async () => { + const client = new ApifyClient({ token: 'test-token', baseUrl: 'https://api.apify.com' }); + const sent: { data?: unknown; contentType?: unknown }[] = []; + client.httpClient.axios.defaults.adapter = async (config) => { + sent.push({ data: config.data, contentType: config.headers.get('Content-Type') }); + return { + status: 201, + statusText: 'Created', + headers: { 'content-type': 'application/json' }, + data: Buffer.from('{"data":{}}'), + config, + request: {}, + }; + }; + const call = async (body: unknown) => { + const context = stubToolCallContext( + { operationId: 'keyValueStore_record_put', pathParams: { storeId: 's', recordKey: 'K' }, body }, + client, + ); + return (await (apifyApiWrite as HelperTool).call(context)) as TextToolResult; + }; + + const results = [await call({ name: 'x' }), await call(42), await call([1, 2])]; + + expect(results.map((result) => result.structuredContent)).toEqual( + Array(3).fill(expect.objectContaining({ statusCode: 201, data: { data: {} } })), + ); + expect(sent).toEqual([ + { data: '{"name":"x"}', contentType: 'application/json' }, + { data: '42', contentType: 'application/json' }, + { data: '[1,2]', contentType: 'application/json' }, + ]); }); it.each([ [{ operationId: 'dataset_delete', pathParams: { datasetId: 'abc' } }, 'Deletion cannot be undone'], [{ operationId: 'users_me_limits_put', body: {} }, 'spending limits'], + [{ operationId: 'actorRun_metamorph_post', pathParams: { runId: 'run-1' } }, 'cannot be undone'], [ { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, `dataset_get is a GET operation with read access; this tool has write access. Call it with ${HELPER_TOOLS.API_READ}.`, ], [{ operationId: 'dataset_put', pathParams: { datasetId: 'abc' } }, 'dataset_put needs a request body.'], + [ + { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, body: null }, + 'Pass the body of dataset_put as a JSON object or array, not as null.', + ], + [ + // A JSON-encoded string would otherwise get past the refused-field check. + { operationId: 'actor_put', pathParams: { actorId: 'john~my-actor' }, body: '{"isPublic":true}' }, + 'Pass the body of actor_put as a JSON object or array, not as a string.', + ], [ { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' }, body: { gracefully: true } }, 'actorRun_abort_post takes no request body.', @@ -359,6 +409,10 @@ describe('apify-api-write', () => { }, 'The API tools do not set isPublic, whatever the value', ], + [ + { operationId: 'actors_post', body: { name: 'x', actorPermissionLevel: 'FULL_PERMISSIONS' } }, + 'The API tools do not set actorPermissionLevel, whatever the value', + ], [ { operationId: 'dataset_put', @@ -367,6 +421,18 @@ describe('apify-api-write', () => { }, 'The API tools do not set generalAccess', ], + [ + { + operationId: 'actors_runs_post', + pathParams: { actorId: 'john~my-actor' }, + query: { forcePermissionLevel: 'FULL_PERMISSIONS' }, + }, + 'The API tools do not set forcePermissionLevel, whatever the value', + ], + [ + { operationId: 'actors_runs_post', pathParams: { actorId: 'john~my-actor' }, query: { waitForFinish: 60 } }, + "call the run's or build's GET operation with waitForFinish; calling this operation again starts another one.", + ], ])('refuses %j without a request', async (args, reason) => { const result = await callTool(apifyApiWrite, args); @@ -384,11 +450,43 @@ describe('apify-api-write', () => { body: { isPublic: true }, }); - expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ data: { isPublic: true } })); + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ data: '{"isPublic":true}' })); expect(result.structuredContent).toMatchObject({ statusCode: 201, data: null }); }); - it('says the request was sent when the response is over the inline limit', async () => { + it('reports a write whose response is over the inline limit as done, not failed', async () => { + const request = { res: { statusCode: 200 } }; + requestMock.mockRejectedValue( + new AxiosError( + `maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, + 'ERR_BAD_RESPONSE', + undefined, + request, + ), + ); + + const result = await callTool(apifyApiWrite, { + operationId: 'dataset_put', + pathParams: { datasetId: 'abc' }, + body: { name: 'leads-2026' }, + }); + + expect(result.isError).toBe(false); + expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); + expect(result.structuredContent).toEqual({ + operationId: 'dataset_put', + method: 'PUT', + path: '/v2/datasets/abc', + statusCode: 200, + data: null, + }); + expect(result.content[1].text).toBe( + `PUT /v2/datasets/abc returned HTTP 200. The response is larger than ${MAX_INLINE_BYTES} bytes, so ` + + 'it is not returned; check the result with an operation with read access.', + ); + }); + + it('says the request was sent when an oversize response has no status', async () => { requestMock.mockRejectedValue( new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), ); @@ -408,14 +506,29 @@ describe('apify-api-write', () => { it('redacts the body in the logged copy without changing the arguments', () => { const args = { operationId: 'actor_version_envVar_put', body: { value: 'secret' } }; - expect(redactArgs?.(args)).toEqual({ operationId: 'actor_version_envVar_put', body: '[REDACTED]' }); + expect(redactArgs?.(args)).toMatchObject({ operationId: 'actor_version_envVar_put', body: '[REDACTED]' }); expect(args.body).toEqual({ value: 'secret' }); }); - it('leaves arguments without a body as they are', () => { - const args = { operationId: 'actorRun_abort_post' }; + it('logs only the declared arguments, so a body under another key is left out', () => { + const args = { operationId: 'actor_version_envVar_put', requestBody: { value: 'secret' } }; - expect(redactArgs?.(args)).toBe(args); + expect(JSON.stringify(redactArgs?.(args))).not.toContain('secret'); + }); + + it('redacts the webhooks query parameter, whose webhooks carry headers', () => { + const args = { + operationId: 'actors_runs_post', + pathParams: { actorId: 'john~my-actor' }, + query: { memory: 1024, webhooks: 'W3siaGVhZGVyc1RlbXBsYXRlIjoic2VjcmV0In1d' }, + }; + + expect(redactArgs?.(args)).toEqual({ + operationId: 'actors_runs_post', + pathParams: { actorId: 'john~my-actor' }, + query: { memory: 1024, webhooks: '[REDACTED]' }, + }); + expect(args.query.webhooks).not.toBe('[REDACTED]'); }); }); }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 0d278be21..06f7196b3 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(19); + expect(index.size).toBe(20); }); it('resolves parameter references and keeps only path and query parameters', () => { From 25edeb15b950c7c946d906ccb8adf2209438f891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 22:47:46 +0200 Subject: [PATCH 008/103] feat: Refuse Actor source and version lists in apify-api-write The source tools own Actor source and environment variable edits, so apify-api-write refuses the body fields that set them, where the operation's schema declares them: - sourceType, sourceFiles, tarballUrl, gitRepoUrl, gitHubGistUrl, and envVars on actor_versions_post, actor_version_post, and actor_version_put. - versions on actors_post and actor_put. Each of these replaces a whole source or list, so whatever the body leaves out is gone, secret variables included, and the API never returns secret values to restore them. Other version fields, such as buildTag, can still be set. The refusal says that Actor source and environment variables are changed through dedicated source tools or Apify Console, without naming a tool that may not be loaded. The single env var operations (actor_version_envVar*) stay available. Each changes one variable by name and leaves the others as they are, so it cannot drop a variable the agent did not name. --- README.md | 2 +- src/tools/api/apify_api_request.ts | 15 +++++++-- src/tools/api/apify_api_spec.ts | 31 +++++++++++++++-- src/tools/api/apify_api_write.ts | 7 ++-- tests/unit/helpers/apify_api_spec_fixture.ts | 30 ++++++++++++++++- tests/unit/tools.apify_api.test.ts | 35 +++++++++++++++++++- tests/unit/tools.apify_api_spec.test.ts | 20 +++++++++-- 7 files changed, 125 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ac57aa2d8..5e348c3b8 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Legend for the **Enabled by default** column: | `apify-api-search` | api | Search the Apify API reference for operations by keywords. | | | `apify-api-details` | api | Get an Apify API operation's parameters and request body schema. | | | `apify-api-read` | api | Call a read-only (GET) Apify API operation by its operation ID. | | -| `apify-api-write` | api | Call a POST or PUT Apify API operation by its operation ID. Deletions, publishing, and pricing changes are refused. | | +| `apify-api-write` | api | Call a POST or PUT Apify API operation by its operation ID. Deletions, publishing, pricing, and Actor source changes are refused. | | > **Note:** > diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index bf19b807d..e091852fb 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -10,7 +10,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import type { ApiAccess, ApiOperation } from './apify_api_spec.js'; -import { API_ACCESS, isRecord } from './apify_api_spec.js'; +import { API_ACCESS, isRecord, SOURCE_BODY_FIELDS } from './apify_api_spec.js'; /** Input fields the read and write tools share. */ export const apiCallArgsShape = { @@ -171,12 +171,21 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str } if (!isRecord(body)) return undefined; const refusedFields = operation.refusedBodyFields.filter((field) => field in body); - if (refusedFields.length > 0) { + const sourceFields = refusedFields.filter((field) => SOURCE_BODY_FIELDS.has(field)); + const otherFields = refusedFields.filter((field) => !SOURCE_BODY_FIELDS.has(field)); + if (otherFields.length > 0) { return ( - `The API tools do not set ${refusedFields.join(', ')}, whatever the value: publishing, pricing, ` + + `The API tools do not set ${otherFields.join(', ')}, whatever the value: publishing, pricing, ` + 'permission, and sharing changes need a dedicated tool or Apify Console. Remove the field and call again.' ); } + if (sourceFields.length > 0) { + return ( + `The API tools do not set ${sourceFields.join(', ')}: each replaces the whole source or list. Actor ` + + 'source and environment variables are changed through dedicated source tools or Apify Console. ' + + 'Remove the field and call again.' + ); + } return undefined; } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 81e7d87da..e6d36efc8 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -80,6 +80,26 @@ const UNDECLARED_REFUSED_BODY_FIELDS: ReadonlyMap = n ['actors_post', ['pricingInfos', 'actorPermissionLevel']], ]); +/** Body fields of an Actor version that set its source or its environment variables. */ +const VERSION_SOURCE_FIELDS = ['sourceType', 'sourceFiles', 'tarballUrl', 'gitRepoUrl', 'gitHubGistUrl', 'envVars']; + +/** + * Body fields that set an Actor's source, versions, or environment variables, by operation. Each + * replaces a whole source or list, so whatever the body leaves out is gone, secret variables included. + * Dedicated source tools own these changes. Like the fields above, they are refused only where the + * operation's schema declares them. The single-variable env var operations stay available. + */ +const SOURCE_BODY_FIELDS_BY_OPERATION: ReadonlyMap = new Map([ + ['actor_versions_post', VERSION_SOURCE_FIELDS], + ['actor_version_post', VERSION_SOURCE_FIELDS], + ['actor_version_put', VERSION_SOURCE_FIELDS], + ['actors_post', ['versions']], + ['actor_put', ['versions']], +]); + +/** Every source field of `SOURCE_BODY_FIELDS_BY_OPERATION`, to tell its refusal from the others. */ +export const SOURCE_BODY_FIELDS: ReadonlySet = new Set([...SOURCE_BODY_FIELDS_BY_OPERATION.values()].flat()); + /** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); @@ -227,9 +247,14 @@ export function buildApiOperationIndex(spec: unknown): Map tags: tags ?? [], parameters: parseParameters(parameters, spec), ...(body && { requestBody: body }), - refusedBodyFields: [...REFUSED_BODY_FIELDS].filter( - (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), - ), + refusedBodyFields: [ + ...[...REFUSED_BODY_FIELDS].filter( + (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), + ), + ...(SOURCE_BODY_FIELDS_BY_OPERATION.get(operationId) ?? []).filter((field) => + declaredBodyFields.has(field), + ), + ], ...resolveAccess(method, path, operationId), }); } diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index e15e3099f..8bb301083 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -36,9 +36,10 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { Call one Apify API operation with write access (POST or PUT), by its operation ID. The server builds the URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} The request is sent once and applies at once. Refused: deletions, synchronous runs, metamorphs, - spending limits, run charging, and fields that publish an Actor or task, change its pricing or - permissions, or change who can read a storage. Returns the response body as the API sends it; a - body over ${MAX_INLINE_BYTES} bytes is not returned. + spending limits, and run charging; fields that publish an Actor or task, change its pricing or + permissions, or change who can read a storage; and fields that replace an Actor's source, versions, + or environment variables, which dedicated source tools or Apify Console change. Returns the response + body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. USAGE: - Use to change something no dedicated tool changes, such as a dataset's name or a webhook. diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index b11a362b6..7449554ca 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -135,6 +135,21 @@ export const API_SPEC_FIXTURE = { }, }, }, + '/v2/actors/{actorId}/versions/{versionNumber}': { + put: { + operationId: 'actor_version_put', + summary: 'Update version', + tags: ['Actors/Versions'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionRequest' } } }, + }, + }, + }, '/v2/actors/{actorId}/runs': { post: { operationId: 'actors_runs_post', @@ -239,7 +254,7 @@ export const API_SPEC_FIXTURE = { // Like the published spec, it does not declare pricingInfos or actorPermissionLevel. CreateActorRequest: { type: 'object', - properties: { name: { type: 'string' }, isPublic: { type: 'boolean' } }, + properties: { name: { type: 'string' }, isPublic: { type: 'boolean' }, versions: { type: 'array' } }, }, UpdateActorRequest: { allOf: [{ $ref: '#/components/schemas/ActorPermissionFields' }], @@ -247,6 +262,19 @@ export const API_SPEC_FIXTURE = { title: { type: 'string' }, isPublic: { type: 'boolean' }, pricingInfos: { type: 'array' }, + versions: { type: 'array' }, + }, + }, + VersionRequest: { + type: 'object', + properties: { + versionNumber: { type: 'string' }, + buildTag: { type: 'string' }, + sourceType: { type: 'string' }, + sourceFiles: { type: 'array' }, + gitRepoUrl: { type: 'string' }, + tarballUrl: { type: 'string' }, + envVars: { type: 'array' }, }, }, ActorPermissionFields: { diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 851e1f014..d0e9f2276 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -163,7 +163,7 @@ describe('apify-api-details', () => { access: 'write', parameters: [{ name: 'actorId', in: 'path', isRequired: true }], requestBody: { isRequired: false }, - refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel'], + refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel', 'versions'], }); }); @@ -421,6 +421,27 @@ describe('apify-api-write', () => { }, 'The API tools do not set generalAccess', ], + [ + { + operationId: 'actor_version_put', + pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + body: { envVars: [{ name: 'API_KEY', value: 'x', isSecret: true }] }, + }, + 'The API tools do not set envVars: each replaces the whole source or list. Actor source and ' + + 'environment variables are changed through dedicated source tools or Apify Console.', + ], + [ + { + operationId: 'actor_version_put', + pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + body: { sourceFiles: [] }, + }, + 'The API tools do not set sourceFiles', + ], + [ + { operationId: 'actor_put', pathParams: { actorId: 'john~my-actor' }, body: { versions: [] } }, + 'The API tools do not set versions', + ], [ { operationId: 'actors_runs_post', @@ -441,6 +462,18 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('sends a version field that does not replace the source, such as buildTag', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: { versionNumber: '0.1', buildTag: 'beta' } })); + + await callTool(apifyApiWrite, { + operationId: 'actor_version_put', + pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + body: { buildTag: 'beta' }, + }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ data: '{"buildTag":"beta"}' })); + }); + it('sends a refused field name when the body is free-form, such as a stored record', async () => { requestMock.mockResolvedValue(mockResponse(201, undefined, '')); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 06f7196b3..32b27d5bd 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(20); + expect(index.size).toBe(21); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -63,14 +63,28 @@ describe('buildApiOperationIndex()', () => { expect(operation?.unavailableReason).toContain(reason); }); - it('refuses publishing, pricing, permission and sharing fields only where the body schema declares them', () => { - expect(index.get('actor_put')?.refusedBodyFields).toEqual(['isPublic', 'pricingInfos', 'actorPermissionLevel']); + it('refuses publishing, pricing, permission, sharing, and source fields where the body takes them', () => { + expect(index.get('actor_put')?.refusedBodyFields).toEqual([ + 'isPublic', + 'pricingInfos', + 'actorPermissionLevel', + 'versions', + ]); expect(index.get('dataset_put')?.refusedBodyFields).toEqual(['generalAccess']); // The API takes these on create, although the create schema does not declare them. expect(index.get('actors_post')?.refusedBodyFields).toEqual([ 'isPublic', 'pricingInfos', 'actorPermissionLevel', + 'versions', + ]); + // Only the source fields the version schema declares; buildTag stays settable. + expect(index.get('actor_version_put')?.refusedBodyFields).toEqual([ + 'sourceType', + 'sourceFiles', + 'tarballUrl', + 'gitRepoUrl', + 'envVars', ]); // A record body is free-form: a record may hold an isPublic key as plain data. expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); From b2a44007f3119ad7bb81c555d7bbc3ef21200f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 23 Sep 2026 12:15:21 +0200 Subject: [PATCH 009/103] feat: Let an internal tool redact its own arguments in logs Every internal tool call logs its arguments: at DEBUG before AJV validation and at INFO as "Calling internal tool". The only redaction was the Skyfire payment ID, so a tool that takes secret values as arguments would write them to the logs. An internal tool can now set redactArgs on its entry. prepareToolCallContext applies it to the logged copy before the payment redaction, so both logs and every shell see the redacted arguments while the tool still gets the real ones. cloneToolEntry keeps the hook on the copies the payment providers decorate, since its JSON clone drops functions. (cherry picked from commit e64fcb93268940d8dd1eba0eba4ebbc106ff100b) --- src/payments/AGENTS.md | 8 +++++++- src/payments/helpers.ts | 8 +++++--- src/types.ts | 6 ++++++ src/utils/tools.ts | 4 ++++ tests/unit/payments.helpers.test.ts | 22 ++++++++++++++++++++++ tests/unit/tools.clone.test.ts | 7 +++++++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/payments/AGENTS.md b/src/payments/AGENTS.md index 2f987cf45..4301c5cea 100644 --- a/src/payments/AGENTS.md +++ b/src/payments/AGENTS.md @@ -24,7 +24,10 @@ The cross-file invariant no single file shows you: flow; forwards the `PAYMENT-SIGNATURE` header. - `const.ts` — `PAYMENT_PROTOCOL_HEADER` and the Skyfire instruction strings (x402's live in `x402.ts`). - `helpers.ts` — `prepareToolCallContext`: the choke point that strips payment fields - before AJV, redacts them for logging, and builds the client. + before AJV, redacts them for logging, and builds the client. The logged copy + (`toolArgsRedacted`, the `logSafeArgs` of both the pre-validation DEBUG log and the + `Calling internal tool` INFO log) first goes through the tool's own `redactArgs`, when an + internal tool has one. ## Rules when editing here @@ -36,6 +39,9 @@ The cross-file invariant no single file shows you: flag. `SKYFIRE_ENABLED_TOOLS` (`const.ts`) is the expected-list the Skyfire integration test asserts against; keep it in sync with the `paymentRequired` tools or that test fails. - `skyfire-pay-id` is injected only at the top level, so `redactSkyfirePayId` redacts top-level only; if you ever nest a payment field, make the redactor recursive or it leaks. +- A tool whose arguments carry secrets sets `redactArgs` on its entry (`HelperTool` in `../types.ts`) + instead of adding a case here. It runs before AJV validation, so it must accept any shape and must + not mutate the arguments; `cloneToolEntry` keeps it on the copies the providers decorate. ## Local commands diff --git a/src/payments/helpers.ts b/src/payments/helpers.ts index afae35c4f..a557a770c 100644 --- a/src/payments/helpers.ts +++ b/src/payments/helpers.ts @@ -1,6 +1,7 @@ import type { REQUEST_ORIGIN } from '../apify_client.js'; import { ApifyClient } from '../apify_client.js'; import type { ApifyToken, ToolEntry } from '../types.js'; +import { TOOL_TYPE } from '../types.js'; import { buildPaymentRequiredResponse, registerPaymentRequiredInterceptor } from '../utils/payment_errors.js'; import type { PaymentMeta, PaymentProvider, RequestHeaders } from './types.js'; @@ -25,7 +26,7 @@ export type PrepareToolCallContextResult = { * This helper centralizes all payment processing: * 1. Validates payment credentials (for tools with `paymentRequired: true`) * 2. Strips payment fields from args (for clean ajv validation and Actor input) - * 3. Redacts sensitive fields for logging + * 3. Redacts sensitive fields for logging: the tool's own `redactArgs`, then the payment fields * 4. Creates an ApifyClient with payment headers or standard token * * Call this BEFORE AJV validation so `toolArgsWithoutPayment` can be validated @@ -47,13 +48,14 @@ export function prepareToolCallContext(input: { requestOrigin?: REQUEST_ORIGIN; }): PrepareToolCallContextResult { const { provider, tool, args, apifyToken, meta, requestHeaders, requestOrigin } = input; + const toolRedactedArgs = tool.type === TOOL_TYPE.INTERNAL && tool.redactArgs ? tool.redactArgs(args) : args; if (!provider) { const apifyClient = new ApifyClient({ token: apifyToken, requestOrigin }); registerPaymentRequiredInterceptor(apifyClient); return { toolArgsWithoutPayment: { ...args }, - toolArgsRedacted: args, + toolArgsRedacted: toolRedactedArgs, apifyClient, }; } @@ -61,7 +63,7 @@ export function prepareToolCallContext(input: { const error = tool.paymentRequired ? provider.validatePayment(args, meta, requestHeaders) : null; const errorData = error && provider.getPaymentRequiredData ? provider.getPaymentRequiredData() : undefined; const toolArgsWithoutPayment = provider.removePaymentFields(args); - const toolArgsRedacted = provider.redactForLogging(args); + const toolArgsRedacted = provider.redactForLogging(toolRedactedArgs); const paymentHeaders = provider.getPaymentHeaders(args, meta, requestHeaders); const apifyClient = diff --git a/src/types.ts b/src/types.ts index 9cabad5d4..a886e8f49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,12 @@ export type HelperTool = ToolBase & { * @returns Promise resolving to the tool's output */ call: (toolArgs: InternalToolArgs) => Promise; + /** + * Returns the copy of the call's arguments that is logged, for a tool whose arguments carry + * secrets. Applied in `prepareToolCallContext`, so every log of the arguments sees it. It runs + * before AJV validation: it must accept arguments of any shape and must not mutate them. + */ + redactArgs?: (args: Record) => Record; }; /** diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 43fdb6a6b..547e19ab6 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -137,6 +137,7 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { // Store the original functions const originalAjvValidate = toolEntry.ajvValidate; const originalCall = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.call : undefined; + const originalRedactArgs = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.redactArgs : undefined; const originalBuildDescription = toolEntry.buildDescription; // Create a deep copy using JSON serialization (excluding functions) @@ -152,6 +153,9 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { if (toolEntry.type === TOOL_TYPE.INTERNAL && originalCall) { (cloned as HelperTool).call = originalCall; } + if (originalRedactArgs) { + (cloned as HelperTool).redactArgs = originalRedactArgs; + } if (originalBuildDescription) { cloned.buildDescription = originalBuildDescription; } diff --git a/tests/unit/payments.helpers.test.ts b/tests/unit/payments.helpers.test.ts index 4effa1307..402723555 100644 --- a/tests/unit/payments.helpers.test.ts +++ b/tests/unit/payments.helpers.test.ts @@ -164,6 +164,28 @@ describe('prepareToolCallContext — Skyfire-like provider', () => { expect((result.toolArgsRedacted as Record)['skyfire-pay-id']).toBe('[REDACTED]'); }); + it("applies the tool's redactArgs and then the payment redaction to logSafeArgs", () => { + const args = { actor: 'apify/rag-web-browser', secret: 'hide-me', 'skyfire-pay-id': 'jwt-token-123' }; + const tool = { + ...makeTool(true), + redactArgs: (toolArgs: Record) => ({ ...toolArgs, secret: '[REDACTED]' }), + }; + const result = prepareToolCallContext({ + provider: makeSkyfireLikeProvider(), + tool, + args, + apifyToken: MOCK_APIFY_TOKEN, + }); + + expect(result.toolArgsRedacted).toEqual({ + actor: 'apify/rag-web-browser', + secret: '[REDACTED]', + 'skyfire-pay-id': '[REDACTED]', + }); + // The tool still gets the real value. + expect(result.toolArgsWithoutPayment).toEqual({ actor: 'apify/rag-web-browser', secret: 'hide-me' }); + }); + it('returns paymentRequiredResult when payment field is missing', () => { const args = { actor: 'apify/rag-web-browser' }; // no skyfire-pay-id const result = prepareToolCallContext({ diff --git a/tests/unit/tools.clone.test.ts b/tests/unit/tools.clone.test.ts index 61c167fc6..7118710a3 100644 --- a/tests/unit/tools.clone.test.ts +++ b/tests/unit/tools.clone.test.ts @@ -87,6 +87,13 @@ describe('cloneToolEntry', () => { expect(typeof cloned.call).toBe('function'); }); + it('keeps redactArgs, so a payment-decorated copy still redacts its logs', () => { + const redactArgs = vi.fn((args: Record) => args); + const cloned = cloneToolEntry(makeInternalTool({ redactArgs })) as HelperTool; + + expect(cloned.redactArgs).toBe(redactArgs); + }); + it('should work for actor tools (no call function)', () => { const original = makeActorTool(); const cloned = cloneToolEntry(original); From 1ef178ad22372f62b67e5aca147c1c72840d3839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 23:16:05 +0200 Subject: [PATCH 010/103] fix: Redact API read arguments, skip error logs on cancel, and rank storage operations first apify-api-read logged its arguments as they came. A storage signature, the webhooks of a synchronous run, or a token the agent put in the query reached the INFO log before the query check could refuse it. This branch now carries the redactArgs hook, and both call tools share one redactor: it keeps operationId and pathParams, keeps the query with the token, signature, and webhooks values redacted, and logs only a marker for a body. It is an allowlist, so other keys are left out of the log. A call the client cancels no longer throws. The engine logged every thrown execution error at ERROR level, so each cancel showed up as a tool failure. The call now returns the empty aborted response, like the run and build tools. Search ranked a run's copy of a storage operation, and unavailable synchronous runs, above the storage's own operation, because their summaries name the storage and the base summary does not. "Add items to a dataset" put dataset_items_post fifth on the published spec. A run-scoped path now loses 2 points when no keyword is about runs or tasks, and an available operation wins a tie over an unavailable one. The fixture now has those competitors, so the ranking test reflects the real spec, and it has the two last-run metamorph operations, so their refusal is tested. The tool text now uses Oxford commas. --- src/payments/AGENTS.md | 5 ++- src/tools/AGENTS.md | 2 + src/tools/api/apify_api_read.ts | 5 ++- src/tools/api/apify_api_request.ts | 35 +++++++++++++-- src/tools/api/apify_api_spec.ts | 31 ++++++++++--- src/types.ts | 3 +- tests/unit/helpers/apify_api_spec_fixture.ts | 38 +++++++++++++++- tests/unit/payments.helpers.test.ts | 13 ++++++ tests/unit/tools.apify_api.test.ts | 47 ++++++++++++++++++-- tests/unit/tools.apify_api_spec.test.ts | 20 +++++++-- 10 files changed, 178 insertions(+), 21 deletions(-) diff --git a/src/payments/AGENTS.md b/src/payments/AGENTS.md index 4301c5cea..4fbebbbf2 100644 --- a/src/payments/AGENTS.md +++ b/src/payments/AGENTS.md @@ -40,8 +40,9 @@ The cross-file invariant no single file shows you: asserts against; keep it in sync with the `paymentRequired` tools or that test fails. - `skyfire-pay-id` is injected only at the top level, so `redactSkyfirePayId` redacts top-level only; if you ever nest a payment field, make the redactor recursive or it leaks. - A tool whose arguments carry secrets sets `redactArgs` on its entry (`HelperTool` in `../types.ts`) - instead of adding a case here. It runs before AJV validation, so it must accept any shape and must - not mutate the arguments; `cloneToolEntry` keeps it on the copies the providers decorate. + instead of adding a case here. It runs before AJV validation, so it must accept any shape, must + not mutate the arguments, and must be an allowlist: the logged copy keeps the undeclared keys AJV + strips only from the tool's copy. `cloneToolEntry` keeps it on the copies the providers decorate. ## Local commands diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index a662afaa0..544657952 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -36,6 +36,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. operation, and call a GET operation. `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason. `apify_api_request.ts` fills in the path, checks the query, and sends one request with no retries. + The call tools log their arguments through `redactApiCallArgs` (`redactArgs`): only the declared + keys, with the body and the `token`, `signature`, and `webhooks` query values redacted. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 8e5ffec55..6e26e8d10 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -11,6 +11,7 @@ import { apiCallArgsShape, buildRequestPath, callApiOperation, + redactApiCallArgs, resolveOperationToCall, validateQueryParams, } from './apify_api_request.js'; @@ -29,7 +30,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { ${MAX_INLINE_BYTES} bytes is not returned. USAGE: - - Use for data no dedicated tool returns, such as webhooks, usage and limits. + - Use for data no dedicated tool returns, such as webhooks, usage, and limits. USAGE EXAMPLES: - user_input: List the webhooks on my account @@ -56,6 +57,8 @@ export const apifyApiRead: ToolEntry = Object.freeze({ idempotentHint: true, openWorldHint: false, }, + // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. + redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 521460e89..986236d14 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -6,10 +6,10 @@ import type { ApifyClient } from '../../apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; import { isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; -import { respondOk, respondUserError } from '../../utils/mcp.js'; +import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import type { ApiAccess, ApiOperation } from './apify_api_spec.js'; -import { API_ACCESS } from './apify_api_spec.js'; +import { API_ACCESS, isRecord } from './apify_api_spec.js'; /** Input fields the read and write tools share. */ export const apiCallArgsShape = { @@ -22,7 +22,7 @@ export const apiCallArgsShape = { .optional() .describe( 'Values for the placeholders in the operation path, by name, for example {"actorId": "apify~web-scraper"}. ' + - 'An Actor, task or storage is given by its ID or as username~name.', + 'An Actor, task, or storage is given by its ID or as username~name.', ), query: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -32,6 +32,31 @@ export const apiCallArgsShape = { ), }; +/** Query parameters whose values grant access or carry secrets: a storage signature, and webhooks with headers. */ +const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; + +/** + * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps operationId and + * pathParams, the query with the values of secret parameters redacted, and only a marker for the body, + * which can carry environment variable values, webhook headers, or stored records. An undeclared + * `token` is redacted too, since it is logged before the query check refuses it. + */ +export function redactApiCallArgs({ operationId, pathParams, query, body }: Record) { + return { + operationId, + pathParams, + query: isRecord(query) + ? Object.fromEntries( + Object.entries(query).map(([name, value]) => [ + name, + SECRET_QUERY_PARAMS.includes(name) ? '[REDACTED]' : value, + ]), + ) + : query, + ...(body !== undefined && { body: '[REDACTED]' }), + }; +} + /** The not-found text, naming the search tool only when the session has it. */ export function formatOperationNotFoundMessage(operationId: string, loadedToolNames: readonly string[]): string { const next = loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) @@ -146,7 +171,7 @@ function formatOversizeMessage(operation: ApiOperation, path: string): string { /** * The request failure as a plain error with its message and code. The axios error keeps the request * config, which holds the Authorization header and the request body, and the tool error log prints - * the whole error. The engine tells a cancelled call by its signal, not by the error type. + * the whole error. */ function toPlainRequestError(error: unknown): Error { if (!(error instanceof Error)) return new Error(String(error)); @@ -185,6 +210,8 @@ export async function callApiOperation(params: { signal: params.signal, }); } catch (error) { + // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. + if (params.signal?.aborted) return respondAborted(); if (isMaxContentLengthAbort(error)) return respondUserError(formatOversizeMessage(operation, path)); throw toPlainRequestError(error); } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 81e7d87da..08c5806be 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -203,8 +203,8 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ } /** - * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations and - * anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. + * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, + * and anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. */ export function buildApiOperationIndex(spec: unknown): Map { const index = new Map(); @@ -278,7 +278,7 @@ const SEARCH_STOP_WORDS: ReadonlySet = new Set([ 'with', ]); -/** Lowercase words of a text, splitting camelCase, snake_case, kebab-case and paths. */ +/** Lowercase words of a text, splitting camelCase, snake_case, kebab-case, and paths. */ function splitWords(text: string): string[] { return text .replace(/([a-z0-9])([A-Z])/g, '$1 $2') @@ -300,15 +300,26 @@ function hasMatchingWord(term: string, words: string[]): boolean { return words.some((word) => word.startsWith(term) || term === `${word}s`); } +/** + * Paths that reach a run's own copy of a resource: the default storages and actions of a run or of the + * last run, and the synchronous runs. A plain ask such as "add items to a dataset" means the storage + * itself, so these rank lower unless the query is about runs or tasks. + */ +const RUN_SCOPED_PATH_REGEX = /^\/v2\/actor-runs\/\{runId\}\/|\/runs\/last\/|\/run-sync/; +const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'task', 'tasks', 'sync']); +const RUN_SCOPED_PENALTY = 2; + /** * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the * operation ID or path, else 1 in the tags, else 2 when it is a verb for the operation's method. A verb - * alone does not match an operation. Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes + * alone does not match an operation. A run-scoped path loses 2 when no keyword is about runs or tasks. + * Ties go to an available operation, then to the shorter path, so `/v2/datasets/{datasetId}` comes * before the same operation on a run's default dataset. */ export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { // One-letter terms, such as the s of "run's", match too many words. const terms = [...new Set(splitWords(query))].filter((term) => term.length > 1 && !SEARCH_STOP_WORDS.has(term)); + const isAboutRuns = terms.some((term) => RUN_SCOPE_TERMS.has(term)); const scored: { operation: ApiOperation; score: number }[] = []; for (const operation of index.values()) { const summaryWords = splitWords(operation.summary); @@ -323,10 +334,18 @@ export function searchApiOperations(index: Map, query: str else if (hasMatchingWord(term, tagWords)) score += 1; else if (verbs.includes(term)) verbScore += 2; } - if (score > 0) scored.push({ operation, score: score + verbScore }); + if (score === 0) continue; + const penalty = !isAboutRuns && RUN_SCOPED_PATH_REGEX.test(operation.path) ? RUN_SCOPED_PENALTY : 0; + scored.push({ operation, score: score + verbScore - penalty }); } + const isUnavailable = (operation: ApiOperation) => (operation.access === API_ACCESS.UNAVAILABLE ? 1 : 0); return scored - .sort((a, b) => b.score - a.score || a.operation.path.length - b.operation.path.length) + .sort( + (a, b) => + b.score - a.score || + isUnavailable(a.operation) - isUnavailable(b.operation) || + a.operation.path.length - b.operation.path.length, + ) .slice(0, limit) .map(({ operation }) => operation); } diff --git a/src/types.ts b/src/types.ts index a886e8f49..e0f563c29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,7 +208,8 @@ export type HelperTool = ToolBase & { /** * Returns the copy of the call's arguments that is logged, for a tool whose arguments carry * secrets. Applied in `prepareToolCallContext`, so every log of the arguments sees it. It runs - * before AJV validation: it must accept arguments of any shape and must not mutate them. + * before AJV validation: it must accept arguments of any shape and must not mutate them, and it + * still sees the undeclared keys AJV strips later, so build the logged copy as an allowlist. */ redactArgs?: (args: Record) => Record; }; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 2db1ac261..86e608459 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -1,6 +1,6 @@ /** * A small OpenAPI document shaped like https://docs.apify.com/api/openapi.json: `$ref` parameters and - * bodies, examples and `x-*` extensions, and one operation of each kind the API tools treat differently. + * bodies, examples, and `x-*` extensions, and one operation of each kind the API tools treat differently. */ export const API_SPEC_FIXTURE = { openapi: '3.1.2', @@ -135,6 +135,42 @@ export const API_SPEC_FIXTURE = { }, }, }, + // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset + // in their summary: search must still rank the storage's own operation first. + '/v2/actors/{actorId}/runs/last/dataset/items': { + post: { + operationId: 'actor_runs_last_dataset_items_post', + summary: "Store items in last run's dataset", + tags: ["Last Actor run's default dataset"], + parameters: [{ $ref: '#/components/parameters/actorId' }], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, + }, + }, + '/v2/actors/{actorId}/run-sync-get-dataset-items': { + post: { + operationId: 'actor_runSyncGetDatasetItems_post', + summary: 'Run Actor synchronously and get dataset items', + tags: ['Actors/Actor runs'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + }, + }, + '/v2/actors/{actorId}/runs/last/metamorph': { + post: { + operationId: 'actor_runs_last_metamorph_post', + summary: "Metamorph Actor's last run", + tags: ["Last Actor run's metamorph"], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, + }, + '/v2/actor-tasks/{actorTaskId}/runs/last/metamorph': { + post: { + operationId: 'actorTask_runs_last_metamorph_post', + summary: "Metamorph Actor task's last run", + tags: ["Last Actor task run's metamorph"], + parameters: [{ name: 'actorTaskId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, '/v2/actors/{actorId}/runs/{runId}': { get: { operationId: 'actors_run_get', diff --git a/tests/unit/payments.helpers.test.ts b/tests/unit/payments.helpers.test.ts index 402723555..a63b8045c 100644 --- a/tests/unit/payments.helpers.test.ts +++ b/tests/unit/payments.helpers.test.ts @@ -94,6 +94,19 @@ describe('prepareToolCallContext — no provider', () => { expect(result.toolArgsWithoutPayment).toEqual(args); }); + it("applies the tool's redactArgs to logSafeArgs", () => { + const args = { actor: 'apify/rag-web-browser', secret: 'hide-me' }; + const tool = { + ...makeTool(false), + redactArgs: (toolArgs: Record) => ({ ...toolArgs, secret: '[REDACTED]' }), + }; + const result = prepareToolCallContext({ provider: undefined, tool, args, apifyToken: MOCK_APIFY_TOKEN }); + + expect(result.toolArgsRedacted).toEqual({ actor: 'apify/rag-web-browser', secret: '[REDACTED]' }); + // The tool still gets the real value. + expect(result.toolArgsWithoutPayment).toEqual({ actor: 'apify/rag-web-browser', secret: 'hide-me' }); + }); + it('AJV mutation of toolArgs does not affect logSafeArgs', () => { const args = { actor: 'apify/rag-web-browser', unknownExtra: 'should-be-stripped' }; const result = prepareToolCallContext({ diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 0d04255eb..ecffae7e5 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -1,11 +1,11 @@ import { ApifyApiError } from 'apify-client'; -import { AxiosError, AxiosHeaders } from 'axios'; +import { AxiosError, AxiosHeaders, CanceledError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; -import { buildRequestPath, validateQueryParams } from '../../src/tools/api/apify_api_request.js'; +import { buildRequestPath, redactApiCallArgs, validateQueryParams } from '../../src/tools/api/apify_api_request.js'; import { apifyApiSearch } from '../../src/tools/api/apify_api_search.js'; import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; @@ -150,7 +150,7 @@ describe('apify-api-search', () => { }); describe('apify-api-details', () => { - it('returns the parameters, body schema and refused fields', async () => { + it('returns the parameters, body schema, and refused fields', async () => { const result = await callTool(apifyApiDetails, { operationId: 'actor_put' }); expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); @@ -283,6 +283,19 @@ describe('apify-api-read', () => { expect(JSON.stringify(error, Object.getOwnPropertyNames(error))).not.toContain('secret-token'); }); + it('returns the empty response for a cancelled call instead of throwing it as a tool error', async () => { + const controller = new AbortController(); + controller.abort(); + requestMock.mockRejectedValue(new CanceledError()); + const context = stubToolCallContext( + { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, + stubClient, + ); + context.signal = controller.signal; + + await expect((apifyApiRead as HelperTool).call(context)).resolves.toEqual({}); + }); + it('describes a binary body instead of returning it', async () => { requestMock.mockResolvedValue(mockResponse(200, Buffer.from([1, 2, 3]), 'application/zip')); @@ -294,4 +307,32 @@ describe('apify-api-read', () => { 'GET /v2/datasets/abc returned HTTP 200 with a binary body (application/zip, 3 bytes), which is not shown.', ); }); + + it('logs a storage signature, webhooks, or an undeclared token only as redacted', () => { + const args = { + operationId: 'dataset_items_get', + pathParams: { datasetId: 'abc' }, + query: { format: 'json', signature: 'sig-secret', webhooks: 'W3t9XQ==', token: 'token-secret' }, + }; + + expect((apifyApiRead as HelperTool).redactArgs).toBe(redactApiCallArgs); + expect(redactApiCallArgs(args)).toEqual({ + operationId: 'dataset_items_get', + pathParams: { datasetId: 'abc' }, + query: { format: 'json', signature: '[REDACTED]', webhooks: '[REDACTED]', token: '[REDACTED]' }, + }); + // The tool itself still gets the real values. + expect(args.query.signature).toBe('sig-secret'); + }); + + it('logs only the declared arguments and a body only as redacted', () => { + const logged = redactApiCallArgs({ operationId: 'dataset_get', body: { value: 'secret' }, extra: 'secret' }); + + expect(logged).toEqual({ + operationId: 'dataset_get', + pathParams: undefined, + query: undefined, + body: '[REDACTED]', + }); + }); }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 0d278be21..b2bcc6d02 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(19); + expect(index.size).toBe(23); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -57,13 +57,16 @@ describe('buildApiOperationIndex()', () => { ['users_me_limits_put', 'spending limits'], ['PostChargeRun', 'Only the Actor itself charges'], ['actorRun_metamorph_post', 'cannot be undone'], + ['actor_runs_last_metamorph_post', 'cannot be undone'], + ['actorTask_runs_last_metamorph_post', 'cannot be undone'], + ['actor_runSyncGetDatasetItems_post', 'waits up to 300 seconds'], ])('makes %s unavailable with the reason', (operationId, reason) => { const operation = index.get(operationId); expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); expect(operation?.unavailableReason).toContain(reason); }); - it('refuses publishing, pricing, permission and sharing fields only where the body schema declares them', () => { + it('refuses publishing, pricing, permission, and sharing fields only where the body schema declares them', () => { expect(index.get('actor_put')?.refusedBodyFields).toEqual(['isPublic', 'pricingInfos', 'actorPermissionLevel']); expect(index.get('dataset_put')?.refusedBodyFields).toEqual(['generalAccess']); // The API takes these on create, although the create schema does not declare them. @@ -106,10 +109,21 @@ describe('searchApiOperations()', () => { it("counts a verb for the operation's method, but not on its own", () => { expect(searchIds('rename dataset')[0]).toBe('dataset_put'); - expect(searchIds('add items to a dataset')[0]).toBe('dataset_items_post'); expect(searchIds('rename')).toEqual([]); }); + it("ranks a storage's own operation above a run's copy of it and above an unavailable one", () => { + // Both competitors name the dataset in their summary; the storage's own operation does not. + const ids = searchIds('add items to a dataset'); + expect(ids[0]).toBe('dataset_items_post'); + expect(ids.indexOf('actor_runs_last_dataset_items_post')).toBeGreaterThan(0); + expect(ids.indexOf('actor_runSyncGetDatasetItems_post')).toBeGreaterThan(0); + }); + + it("keeps a run's copy first when the query is about runs", () => { + expect(searchIds("store items in last run's dataset")[0]).toBe('actor_runs_last_dataset_items_post'); + }); + it('caps the results at the limit', () => { expect(searchApiOperations(index, 'dataset', 2)).toHaveLength(2); }); From 66da60237390ffecf06899749fcad832e5566616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 27 Sep 2026 23:18:46 +0200 Subject: [PATCH 011/103] fix: Make actor_versions_post unavailable and list every refused body field at once Creating an Actor version always needs a field apify-api-write refuses: the API requires sourceType on create, and sourceFiles with the SOURCE_FILES type. So actor_versions_post was listed as write access but no call could succeed, and an agent could loop between the refusal and the API's 400. It is now unavailable, with a reason that names no tool. Updating a version stays available, since the API checks only the fields the body sends, so buildTag alone works. validateRequestBody now builds one refusal for every refused field in the body, publishing and source fields together, so the agent does not learn of them one call at a time. The source reason no longer says each field replaces the whole source or list, which is wrong for single values and for a new Actor. When envVars is refused, the text points to actor_version_envVars_post and actor_version_envVar_put, which this same tool calls and which each change one variable. These are operation IDs, not tool names, so the loaded-tool rule does not apply. When nothing else is left in the body, it says not to call again, since the rest would be an empty update. apify-api-write now uses the redactor it shares with apify-api-read, so an undeclared token in the query is redacted in its logs too. The call output schema says data is also null for a write response over the inline limit. The fixture now has actor_versions_post, actor_version_post, and gitHubGistUrl, so their refusals are tested, and a new test covers the write-to-read refusal in a session without apify-api-read. --- src/tools/AGENTS.md | 4 +- src/tools/api/apify_api_request.ts | 30 ++++++++--- src/tools/api/apify_api_spec.ts | 12 +++-- src/tools/api/apify_api_write.ts | 20 +++---- src/tools/structured_output_schemas.ts | 6 ++- tests/unit/helpers/apify_api_spec_fixture.ts | 26 +++++++++ tests/unit/tools.apify_api.test.ts | 56 +++++++++++++++++++- tests/unit/tools.apify_api_spec.test.ts | 10 ++-- 8 files changed, 135 insertions(+), 29 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index bd2b51be5..beec58c7c 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -35,7 +35,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get one operation, and call it with read access (GET) or write access (POST, PUT). `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) and decides each operation's access: read, - write, or unavailable with a reason, plus the body fields the write tool refuses. + write, or unavailable with a reason, plus the body fields the write tool refuses. Fields that set + an Actor's source, versions, or whole env var list are refused; the single-variable env var + operations stay callable, since each changes one variable. `apify_api_request.ts` fills in the path, checks the query and body, and sends one request with no retries. The call tools log their arguments through `redactApiCallArgs` (`redactArgs`): only the declared keys, with the body and the `token`, `signature`, and `webhooks` query values redacted. diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 713ef58e2..a2c083ff3 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -196,22 +196,38 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str } if (!isRecord(body)) return undefined; const refusedFields = operation.refusedBodyFields.filter((field) => field in body); + if (refusedFields.length === 0) return undefined; + // One refusal lists every refused field, so the agent does not learn of them one call at a time. const sourceFields = refusedFields.filter((field) => SOURCE_BODY_FIELDS.has(field)); const otherFields = refusedFields.filter((field) => !SOURCE_BODY_FIELDS.has(field)); + const sentences: string[] = []; if (otherFields.length > 0) { - return ( + sentences.push( `The API tools do not set ${otherFields.join(', ')}, whatever the value: publishing, pricing, ` + - 'permission, and sharing changes need a dedicated tool or Apify Console. Remove the field and call again.' + 'permission, and sharing changes need a dedicated tool or Apify Console.', ); } if (sourceFields.length > 0) { - return ( - `The API tools do not set ${sourceFields.join(', ')}: each replaces the whole source or list. Actor ` + - 'source and environment variables are changed through dedicated source tools or Apify Console. ' + - 'Remove the field and call again.' + sentences.push( + `The API tools do not set ${sourceFields.join(', ')}: they set an Actor's source, versions, or ` + + 'environment variables, which dedicated source tools or Apify Console change.', ); } - return undefined; + // Operation IDs, not tool names: this same tool calls them, and each changes one variable. + if (sourceFields.includes('envVars')) { + sentences.push( + 'To add or change one environment variable, call actor_version_envVars_post or ' + + 'actor_version_envVar_put; each leaves the other variables as they are.', + ); + } + // Sending the rest of an otherwise empty body would succeed and change nothing. + const isNothingElseSet = Object.keys(body).every((field) => refusedFields.includes(field)); + sentences.push( + isNothingElseSet + ? 'Without the refused fields the body sets nothing, so do not call again with it.' + : 'Remove the refused fields and call again.', + ); + return sentences.join(' '); } function formatOversizeMessage(operation: ApiOperation, path: string): string { diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 409c35c73..dd58da05e 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -61,6 +61,13 @@ const UNAVAILABLE_OPERATION_REASONS: ReadonlyMap = new Map([ ['actorRun_metamorph_post', METAMORPH_REASON], ['actor_runs_last_metamorph_post', METAMORPH_REASON], ['actorTask_runs_last_metamorph_post', METAMORPH_REASON], + // The API needs sourceType on create, and sourceFiles with the SOURCE_FILES type, which the write + // tool refuses; a create without them fails. Updating a version checks only the fields it sends. + [ + 'actor_versions_post', + "It sets the new version's source and environment variables, which dedicated source tools or Apify " + + 'Console change.', + ], ]); /** @@ -84,13 +91,12 @@ const UNDECLARED_REFUSED_BODY_FIELDS: ReadonlyMap = n const VERSION_SOURCE_FIELDS = ['sourceType', 'sourceFiles', 'tarballUrl', 'gitRepoUrl', 'gitHubGistUrl', 'envVars']; /** - * Body fields that set an Actor's source, versions, or environment variables, by operation. Each - * replaces a whole source or list, so whatever the body leaves out is gone, secret variables included. + * Body fields that set an Actor's source, versions, or environment variables, by operation. Most replace + * a whole source or list, so whatever the body leaves out is gone, secret variables included. * Dedicated source tools own these changes. Like the fields above, they are refused only where the * operation's schema declares them. The single-variable env var operations stay available. */ const SOURCE_BODY_FIELDS_BY_OPERATION: ReadonlyMap = new Map([ - ['actor_versions_post', VERSION_SOURCE_FIELDS], ['actor_version_post', VERSION_SOURCE_FIELDS], ['actor_version_put', VERSION_SOURCE_FIELDS], ['actors_post', ['versions']], diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 8bb301083..028773d40 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -11,11 +11,12 @@ import { apiCallArgsShape, buildRequestPath, callApiOperation, + redactApiCallArgs, resolveOperationToCall, validateQueryParams, validateRequestBody, } from './apify_api_request.js'; -import { API_ACCESS, fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; +import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiWriteArgs = z.object({ ...apiCallArgsShape, @@ -36,10 +37,10 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { Call one Apify API operation with write access (POST or PUT), by its operation ID. The server builds the URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} The request is sent once and applies at once. Refused: deletions, synchronous runs, metamorphs, - spending limits, and run charging; fields that publish an Actor or task, change its pricing or - permissions, or change who can read a storage; and fields that replace an Actor's source, versions, - or environment variables, which dedicated source tools or Apify Console change. Returns the response - body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. + spending limits, run charging, and creating Actor versions; fields that publish an Actor or task, + change its pricing or permissions, or change who can read a storage; and fields that set an Actor's + source, versions, or environment variables, which dedicated source tools or Apify Console change. + Returns the response body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. USAGE: - Use to change something no dedicated tool changes, such as a dataset's name or a webhook. @@ -71,13 +72,8 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ openWorldHint: false, }, // A body can carry secrets: environment variable values, webhook headers, stored records. So can the - // webhooks query parameter of a run, whose webhooks carry headers. Undeclared keys are left out. - redactArgs: ({ operationId, pathParams, query, body }: Record) => ({ - operationId, - pathParams, - query: isRecord(query) && 'webhooks' in query ? { ...query, webhooks: '[REDACTED]' } : query, - ...(body !== undefined && { body: '[REDACTED]' }), - }), + // webhooks query parameter of a run, whose webhooks carry headers. + redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 351e259af..cc7d442e5 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -970,7 +970,11 @@ export const apifyApiCallOutputSchema = { path: { type: 'string', description: 'Request path, with the path parameters filled in' }, statusCode: { type: 'integer', description: 'HTTP status code' }, contentType: { type: 'string', description: 'Content-Type of the response' }, - data: { description: 'Response body: parsed JSON, text, or null for an empty or binary body' }, + data: { + description: + 'Response body: parsed JSON, text, or null for an empty or binary body, or for a write response ' + + 'over the inline limit', + }, }, required: ['operationId', 'method', 'path', 'statusCode', 'data'], }; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index f3c1560e1..1e972497f 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -135,7 +135,32 @@ export const API_SPEC_FIXTURE = { }, }, }, + '/v2/actors/{actorId}/versions': { + post: { + operationId: 'actor_versions_post', + summary: 'Create version', + tags: ['Actors/Actor versions'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionRequest' } } }, + }, + }, + }, '/v2/actors/{actorId}/versions/{versionNumber}': { + post: { + operationId: 'actor_version_post', + summary: 'Update version (POST)', + tags: ['Actors/Actor versions'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionRequest' } } }, + }, + }, put: { operationId: 'actor_version_put', summary: 'Update version', @@ -310,6 +335,7 @@ export const API_SPEC_FIXTURE = { sourceFiles: { type: 'array' }, gitRepoUrl: { type: 'string' }, tarballUrl: { type: 'string' }, + gitHubGistUrl: { type: 'string' }, envVars: { type: 'array' }, }, }, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index b54cb121d..b14e9c0e5 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -468,8 +468,10 @@ describe('apify-api-write', () => { pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, body: { envVars: [{ name: 'API_KEY', value: 'x', isSecret: true }] }, }, - 'The API tools do not set envVars: each replaces the whole source or list. Actor source and ' + - 'environment variables are changed through dedicated source tools or Apify Console.', + "The API tools do not set envVars: they set an Actor's source, versions, or environment variables, " + + 'which dedicated source tools or Apify Console change. To add or change one environment ' + + 'variable, call actor_version_envVars_post or actor_version_envVar_put; each leaves the other ' + + 'variables as they are. Without the refused fields the body sets nothing, so do not call again with it.', ], [ { @@ -483,6 +485,22 @@ describe('apify-api-write', () => { { operationId: 'actor_put', pathParams: { actorId: 'john~my-actor' }, body: { versions: [] } }, 'The API tools do not set versions', ], + [ + { + operationId: 'actor_version_post', + pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + body: { buildTag: 'beta', gitHubGistUrl: 'https://gist.github.com/x' }, + }, + 'The API tools do not set gitHubGistUrl', + ], + [ + { + operationId: 'actor_versions_post', + pathParams: { actorId: 'john~my-actor' }, + body: { versionNumber: '0.2', envVars: [{ name: 'API_KEY', value: 'x' }] }, + }, + "The API tools do not call actor_versions_post. It sets the new version's source", + ], [ { operationId: 'actors_runs_post', @@ -503,6 +521,33 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('lists every refused field in one refusal and says to call again when the rest of the body sets something', async () => { + const result = await callTool(apifyApiWrite, { + operationId: 'actor_put', + pathParams: { actorId: 'john~my-actor' }, + body: { title: 'T', isPublic: true, versions: [] }, + }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + 'The API tools do not set isPublic, whatever the value: publishing, pricing, permission, and sharing ' + + "changes need a dedicated tool or Apify Console. The API tools do not set versions: they set an Actor's " + + 'source, versions, or environment variables, which dedicated source tools or Apify Console change. ' + + 'Remove the refused fields and call again.', + ); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('does not name the read tool for a read operation when the session lacks it', async () => { + const result = await callTool(apifyApiWrite, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, [ + HELPER_TOOLS.API_WRITE, + ]); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).not.toContain(HELPER_TOOLS.API_READ); + expect(result.content[0].text).toContain('No tool in this session has read access.'); + }); + it('sends a version field that does not replace the source, such as buildTag', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { versionNumber: '0.1', buildTag: 'beta' } })); @@ -577,6 +622,13 @@ describe('apify-api-write', () => { describe('redactArgs()', () => { const { redactArgs } = apifyApiWrite as HelperTool; + it('shares the read tool redactor, so an undeclared token is redacted too', () => { + const args = { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, query: { token: 'secret' } }; + + expect(redactArgs).toBe(redactApiCallArgs); + expect(JSON.stringify(redactArgs?.(args))).not.toContain('secret'); + }); + it('redacts the body in the logged copy without changing the arguments', () => { const args = { operationId: 'actor_version_envVar_put', body: { value: 'secret' } }; diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 009911f10..6ca847d42 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(25); + expect(index.size).toBe(27); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -60,6 +60,7 @@ describe('buildApiOperationIndex()', () => { ['actor_runs_last_metamorph_post', 'cannot be undone'], ['actorTask_runs_last_metamorph_post', 'cannot be undone'], ['actor_runSyncGetDatasetItems_post', 'waits up to 300 seconds'], + ['actor_versions_post', "sets the new version's source and environment variables"], ])('makes %s unavailable with the reason', (operationId, reason) => { const operation = index.get(operationId); expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); @@ -82,13 +83,16 @@ describe('buildApiOperationIndex()', () => { 'versions', ]); // Only the source fields the version schema declares; buildTag stays settable. - expect(index.get('actor_version_put')?.refusedBodyFields).toEqual([ + const versionSourceFields = [ 'sourceType', 'sourceFiles', 'tarballUrl', 'gitRepoUrl', + 'gitHubGistUrl', 'envVars', - ]); + ]; + expect(index.get('actor_version_put')?.refusedBodyFields).toEqual(versionSourceFields); + expect(index.get('actor_version_post')?.refusedBodyFields).toEqual(versionSourceFields); // A record body is free-form: a record may hold an isPublic key as plain data. expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); }); From 362ba9a5dc637c935b89f10c3fe810042d3bcf19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 28 Sep 2026 23:04:35 +0200 Subject: [PATCH 012/103] feat: Identify Apify API operations by path instead of operation ID The agent now gives a real API path, as `apify api GET /v2/actor-runs` does in the CLI. Operation IDs are no longer an input or an output; they stay internal keys for the access rules. - apify-api-search returns each operation's method, path template, summary, access, and docsUrl, its page in the API reference. The page is https://docs.apify.com/api/v2/ plus the operation ID in kebab case; every operation in the published spec was checked and returns 200. - apify-api-details takes a path, as a template or a real path, and an optional method. Without a method it returns every operation on the path. - apify-api-read takes a path and a query, and always sends a GET. A path without a GET operation is refused with the methods it has. - The path must start with /v2/ and hold its values. ?, #, empty segments, template placeholders, and . or .. segments (also encoded) are refused. Each segment is decoded and encoded again, so encoded and unencoded values both work and ~ stays. When several templates match, the one with more literal segments wins. - pathParams and the username/name to username~name rewrite are gone: the agent writes ~ itself, and the no-match message says so. - Messages and results name the method and path. The logs keep path, method, and query, with the body and secret query values redacted. --- README.md | 6 +- src/tools/AGENTS.md | 10 +- src/tools/api/apify_api_details.ts | 68 +++-- src/tools/api/apify_api_read.ts | 28 ++- src/tools/api/apify_api_request.ts | 211 ++++++++++------ src/tools/api/apify_api_search.ts | 11 +- src/tools/api/apify_api_spec.ts | 33 ++- src/tools/structured_output_schemas.ts | 35 ++- tests/unit/helpers/apify_api_spec_fixture.ts | 20 ++ tests/unit/tools.apify_api.test.ts | 249 ++++++++++++------- tests/unit/tools.apify_api_spec.test.ts | 10 +- 11 files changed, 451 insertions(+), 230 deletions(-) diff --git a/README.md b/README.md index cfe2ebf7b..f638e3239 100644 --- a/README.md +++ b/README.md @@ -294,9 +294,9 @@ Legend for the **Enabled by default** column: | `get-actor-build` | builds | Get an Actor build's status. | | | `get-actor-build-log` | builds | Retrieve the logs for a specific Actor build. | | | `build-actor` | builds | Build an Actor version and wait a bounded time for the build to finish. | | -| `apify-api-search` | api | Search the Apify API reference for operations by keywords. | | -| `apify-api-details` | api | Get an Apify API operation's parameters and request body schema. | | -| `apify-api-read` | api | Call a read-only (GET) Apify API operation by its operation ID. | | +| `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | +| `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | +| `apify-api-read` | api | Send a GET request to an Apify API path, such as `/v2/actor-runs`. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 544657952..11d55f1df 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -32,10 +32,12 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. `build-actor` (start a build of one version and wait for it); `build_helpers.ts` holds the allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get one - operation, and call a GET operation. `apify_api_spec.ts` builds the operation index from the spec - (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason. - `apify_api_request.ts` fills in the path, checks the query, and sends one request with no retries. + - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the + operations on a path, and send a GET to a path. `apify_api_spec.ts` builds the operation index from + the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with + a reason. Operation IDs are internal keys for these rules; the tools take and return method and + path only. `apify_api_request.ts` normalizes the real path the agent gives, matches it to a path + template (more literal segments win), checks the query, and sends one request with no retries. The call tools log their arguments through `redactApiCallArgs` (`redactArgs`): only the declared keys, with the body and the `token`, `signature`, and `webhooks` query values redacted. - `docs/` — search and fetch Apify docs. diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 0cc8cd3d2..4dc595e7e 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,21 +7,30 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; -import { apiCallArgsShape, formatOperationNotFoundMessage } from './apify_api_request.js'; -import { fetchApiOperationIndex } from './apify_api_spec.js'; +import { resolveMethodOperation, resolvePathOperations } from './apify_api_request.js'; +import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ - operationId: apiCallArgsShape.operationId, + path: z + .string() + .min(1) + .describe( + 'The API path, as a template such as /v2/datasets/{datasetId} or with its values in it, ' + + 'such as /v2/datasets/abc.', + ), + method: z + .enum(API_METHODS) + .optional() + .describe('HTTP method of the operation. Omit it to get every operation on the path.'), }); function buildDescription({ hasTool }: ToolDescriptionContext): string { - const findOperation = hasTool(HELPER_TOOLS.API_SEARCH) - ? ` Find the operation ID with ${HELPER_TOOLS.API_SEARCH}.` - : ''; + const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; return dedent` - Get one Apify API operation: its method, path, description, path and query parameters, and the - JSON schema of its request body. Also returns its access (read, write, or unavailable with the - reason) and the body fields the API tools refuse to set.${findOperation} + Get the Apify API operations on a path: for each method, its description, path and query + parameters, and the JSON schema of its request body. Also returns each operation's access (read, + write, or unavailable with the reason) and the body fields the API tools refuse to set.${findPath} + Give a method to get only that operation, for example {"path": "/v2/datasets/abc", "method": "PUT"}. USAGE: - Use before calling an operation, to pass the right parameters and body. @@ -33,7 +42,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Returns one operation of the published Apify API spec, https://docs.apify.com/api/v2. + * Returns the operations on one path of the published Apify API spec, https://docs.apify.com/api/v2. */ export const apifyApiDetails: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -54,23 +63,34 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); - const operation = index.get(parsed.operationId); - if (!operation) { - return respondUserError(formatOperationNotFoundMessage(parsed.operationId, toolArgs.loadedToolNames)); + const matched = resolvePathOperations({ + index, + path: parsed.path, + loadedToolNames: toolArgs.loadedToolNames, + canBeTemplate: true, + }); + if ('error' in matched) return respondUserError(matched.error); + let { operations } = matched; + if (parsed.method) { + const resolved = resolveMethodOperation(matched.path, operations, parsed.method); + if ('error' in resolved) return respondUserError(resolved.error); + operations = [resolved.operation]; } const result = { - operationId: operation.operationId, - method: operation.method, - path: operation.path, - summary: operation.summary, - description: operation.description, - access: operation.access, - ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), - parameters: operation.parameters, - ...(operation.requestBody && { requestBody: operation.requestBody }), - refusedBodyFields: operation.refusedBodyFields, + operations: operations.map((operation) => ({ + method: operation.method, + path: operation.path, + summary: operation.summary, + description: operation.description, + access: operation.access, + ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), + parameters: operation.parameters, + ...(operation.requestBody && { requestBody: operation.requestBody }), + refusedBodyFields: operation.refusedBodyFields, + })), }; - const summary = `${operation.operationId}: ${operation.method} ${operation.path}, ${operation.access} access.`; + const methods = operations.map((operation) => `${operation.method} (${operation.access})`).join(', '); + const summary = `${operations[0].path}: ${methods}.`; return respondOk([JSON.stringify(result), summary], { structuredContent: result }); }, } as const); diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 6e26e8d10..84e1f2a34 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -9,26 +9,29 @@ import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; import { apiCallArgsShape, - buildRequestPath, callApiOperation, redactApiCallArgs, - resolveOperationToCall, + resolvePathOperations, + resolveReadOperation, validateQueryParams, } from './apify_api_request.js'; -import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; +import { fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiReadArgs = z.object(apiCallArgsShape); function buildDescription({ hasTool }: ToolDescriptionContext): string { + const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? `\nFind the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) - ? `\nGet the operation's parameters first with ${HELPER_TOOLS.API_DETAILS}.` + ? `\nGet the operation's query parameters first with ${HELPER_TOOLS.API_DETAILS}.` : ''; return dedent` - Call one Apify API operation with read access (GET), by its operation ID. The server builds the - URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} + Send a GET request to the Apify API at a path with its values in it, such as /v2/actor-runs/abc. + The server adds the host and the API token: never pass a URL or a token.${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. + Example call: {"path": "/v2/datasets/abc/items", "query": {"format": "json", "limit": 10}} + USAGE: - Use for data no dedicated tool returns, such as webhooks, usage, and limits. @@ -39,7 +42,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Calls one GET operation of the published Apify API spec, https://docs.apify.com/api/v2. + * Sends a GET to a path of the published Apify API spec, https://docs.apify.com/api/v2. */ export const apifyApiRead: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -62,24 +65,23 @@ export const apifyApiRead: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); - const resolved = resolveOperationToCall({ + const matched = resolvePathOperations({ index, - operationId: parsed.operationId, - access: API_ACCESS.READ, + path: parsed.path, loadedToolNames: toolArgs.loadedToolNames, }); + if ('error' in matched) return respondUserError(matched.error); + const resolved = resolveReadOperation(matched.path, matched.operations); if ('error' in resolved) return respondUserError(resolved.error); const { operation } = resolved; - const request = buildRequestPath(operation, parsed.pathParams); - if ('error' in request) return respondUserError(request.error); const queryError = validateQueryParams(operation, parsed.query); if (queryError) return respondUserError(queryError); return callApiOperation({ client: toolArgs.apifyClient, operation, - path: request.path, + path: matched.path, query: parsed.query, signal: toolArgs.signal, }); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 986236d14..d78879492 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -8,21 +8,17 @@ import { isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; -import type { ApiAccess, ApiOperation } from './apify_api_spec.js'; +import type { ApiOperation } from './apify_api_spec.js'; import { API_ACCESS, isRecord } from './apify_api_spec.js'; /** Input fields the read and write tools share. */ export const apiCallArgsShape = { - operationId: z + path: z .string() .min(1) - .describe('The operation ID, for example actor_get. Operation IDs are case-sensitive.'), - pathParams: z - .record(z.string(), z.string()) - .optional() .describe( - 'Values for the placeholders in the operation path, by name, for example {"actorId": "apify~web-scraper"}. ' + - 'An Actor, task, or storage is given by its ID or as username~name.', + 'The API path with its values in it, for example /v2/datasets/abc/items. A name is written ' + + 'username~name, as in /v2/actors/john~my-actor. Query parameters go in query, not in the path.', ), query: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -36,15 +32,15 @@ export const apiCallArgsShape = { const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; /** - * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps operationId and - * pathParams, the query with the values of secret parameters redacted, and only a marker for the body, + * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps the path and + * method, the query with the values of secret parameters redacted, and only a marker for the body, * which can carry environment variable values, webhook headers, or stored records. An undeclared * `token` is redacted too, since it is logged before the query check refuses it. */ -export function redactApiCallArgs({ operationId, pathParams, query, body }: Record) { +export function redactApiCallArgs({ path, method, query, body }: Record) { return { - operationId, - pathParams, + path, + method, query: isRecord(query) ? Object.fromEntries( Object.entries(query).map(([name, value]) => [ @@ -57,72 +53,146 @@ export function redactApiCallArgs({ operationId, pathParams, query, body }: Reco }; } -/** The not-found text, naming the search tool only when the session has it. */ -export function formatOperationNotFoundMessage(operationId: string, loadedToolNames: readonly string[]): string { - const next = loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) - ? `Find it with ${HELPER_TOOLS.API_SEARCH}.` - : 'Operation IDs are case-sensitive.'; - return `API operation ${operationId} not found. ${next}`; +const API_PATH_PREFIX = '/v2/'; + +/** A template segment that is one path parameter, such as `{datasetId}`. */ +const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; + +/** + * Checks a request path and returns its segments after `/v2/`, each decoded and encoded again, so a + * value works both encoded and not. `~` stays as it is. A template placeholder is refused unless + * `canBeTemplate` is set: the API would take `{datasetId}` as the value. + */ +export function parseApiPath(path: string, canBeTemplate = false): { segments: string[] } | { error: string } { + if (!path.startsWith(API_PATH_PREFIX)) { + return { error: `The path must start with ${API_PATH_PREFIX}, for example /v2/actor-runs. Do not pass a URL.` }; + } + if (path.includes('?') || path.includes('#')) { + return { error: 'The path cannot hold ? or #. Pass query parameters in query.' }; + } + const segments: string[] = []; + for (const rawSegment of path.slice(API_PATH_PREFIX.length).split('/')) { + if (!rawSegment) return { error: `The path ${path} has an empty segment. Remove the extra slash.` }; + if (!canBeTemplate && PATH_PARAMETER_SEGMENT_REGEX.test(rawSegment)) { + return { error: `Replace ${rawSegment} in the path with its value.` }; + } + let value: string; + try { + value = decodeURIComponent(rawSegment); + } catch { + return { error: `The path segment ${rawSegment} is not valid URL encoding. Write a % as %25.` }; + } + // Encoded or not, `.` and `..` would move the request to another route. + if (value === '.' || value === '..') return { error: `The path cannot have a ${value} segment.` }; + segments.push(encodeURIComponent(value)); + } + return { segments }; } -/** The operation a tool with the given access may call, or the reason it may not. */ -export function resolveOperationToCall(params: { - index: Map; - operationId: string; - access: Exclude; - loadedToolNames: readonly string[]; -}): { operation: ApiOperation } | { error: string } { - const { index, operationId, access, loadedToolNames } = params; - const operation = index.get(operationId); - if (!operation) return { error: formatOperationNotFoundMessage(operationId, loadedToolNames) }; - if (operation.access === API_ACCESS.UNAVAILABLE) { - return { error: `The API tools do not call ${operationId}. ${operation.unavailableReason}` }; +/** How many literal segments of the template match the path segments, or `undefined` when it does not match. */ +function countMatchingLiterals(template: string, segments: string[]): number | undefined { + const templateSegments = template.slice(API_PATH_PREFIX.length).split('/'); + if (templateSegments.length !== segments.length) return undefined; + let literalCount = 0; + for (const [position, templateSegment] of templateSegments.entries()) { + if (PATH_PARAMETER_SEGMENT_REGEX.test(templateSegment)) continue; + if (templateSegment !== segments[position]) return undefined; + literalCount += 1; } - if (operation.access !== access) { - return { - error: - `${operationId} is a ${operation.method} operation with ${operation.access} access; this tool has ` + - `${access} access. No tool in this session has ${operation.access} access.`, - }; + return literalCount; +} + +/** + * The operations on the one path template the segments match, in index order (GET, POST, PUT, DELETE). + * When several templates match, the one with more literal segments wins, so `/v2/actors/x/runs/last` + * is the last run, not a run with the ID `last`. The published spec has no tie; the first template wins one. + */ +function findPathOperations(index: Map, segments: string[]): ApiOperation[] { + let bestLiteralCount = -1; + let operations: ApiOperation[] = []; + for (const operation of index.values()) { + const literalCount = countMatchingLiterals(operation.path, segments); + if (literalCount === undefined || literalCount < bestLiteralCount) continue; + if (literalCount > bestLiteralCount) { + bestLiteralCount = literalCount; + operations = [operation]; + } else if (operation.path === operations[0].path) { + operations.push(operation); + } } - return { operation }; + return operations; } -const PATH_PARAMETER_REGEX = /\{([^}]+)\}/g; +/** Method names in a sentence, with an Oxford comma: `GET, PUT, and DELETE`. */ +function formatMethodList(operations: readonly ApiOperation[]): string { + const methods = operations.map((operation) => operation.method); + if (methods.length <= 2) return methods.join(' and '); + return `${methods.slice(0, -1).join(', ')}, and ${methods.at(-1)}`; +} -/** Path parameters the API also resolves as username~name; other values, such as record keys, are sent as they are. */ -const RESOURCE_ID_PARAMETERS: ReadonlySet = new Set([ - 'actorId', - 'actorTaskId', - 'storeId', - 'datasetId', - 'queueId', - 'scheduleId', -]); +/** `method POST` or `methods GET and PUT`. */ +function formatMatchedMethods(operations: readonly ApiOperation[]): string { + return `${operations.length === 1 ? 'method' : 'methods'} ${formatMethodList(operations)}`; +} -/** Fills the operation's path template; returns the reason instead when the parameters do not fit it. */ -export function buildRequestPath( - operation: ApiOperation, - pathParams: Record = {}, -): { path: string } | { error: string } { - const names = [...operation.path.matchAll(PATH_PARAMETER_REGEX)].map((match) => match[1]); - const unknownNames = Object.keys(pathParams).filter((name) => !names.includes(name)); - if (unknownNames.length > 0) { +/** + * The operations on the path template a request path matches, with the path as it is sent, or why + * the path is refused. The refusal names the search tool only when the session has it. + */ +export function resolvePathOperations(params: { + index: Map; + path: string; + loadedToolNames: readonly string[]; + /** Lets the path be a template, such as `/v2/datasets/{datasetId}`; only for describing operations. */ + canBeTemplate?: boolean; +}): { path: string; operations: ApiOperation[] } | { error: string } { + const parsed = parseApiPath(params.path, params.canBeTemplate); + if ('error' in parsed) return parsed; + const path = `${API_PATH_PREFIX}${parsed.segments.join('/')}`; + const operations = findPathOperations(params.index, parsed.segments); + if (operations.length > 0) return { path, operations }; + const next = params.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) + ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` + : ''; + return { + error: + `No Apify API operation matches the path ${path}. Write the path with its values in it; ` + + `a name is written username~name, as in /v2/actors/john~my-actor.${next}`, + }; +} + +/** The operation among the matched ones with the method, or why there is none. */ +export function resolveMethodOperation( + path: string, + operations: readonly ApiOperation[], + method: string, +): { operation: ApiOperation } | { error: string } { + const operation = operations.find((candidate) => candidate.method === method); + if (operation) return { operation }; + return { error: `The path ${path} has no ${method} operation; it matches ${formatMatchedMethods(operations)}.` }; +} + +/** The refusal of an operation the API tools do not call, or `undefined` when they call it. */ +export function formatUnavailableMessage(operation: ApiOperation, path: string): string | undefined { + if (operation.access !== API_ACCESS.UNAVAILABLE) return undefined; + return `The API tools do not call ${operation.method} ${path}. ${operation.unavailableReason}`; +} + +/** The GET operation on the matched path, or why the read tool may not call the path. */ +export function resolveReadOperation( + path: string, + operations: readonly ApiOperation[], +): { operation: ApiOperation } | { error: string } { + const operation = operations.find((candidate) => candidate.method === 'GET'); + if (!operation) { return { - error: `${operation.operationId} has no path parameter ${unknownNames.join(', ')}. Its path is ${operation.path}.`, + error: + `The path ${path} matches ${formatMatchedMethods(operations)}, not GET; this tool sends only GET. ` + + 'No tool in this session has write access.', }; } - let { path } = operation; - for (const name of names) { - const value = pathParams[name]; - if (!value) return { error: `Missing path parameter ${name}. The path is ${operation.path}.` }; - // Encoded, `.` and `..` stay as they are and would move the request to another route. - if (value === '.' || value === '..') return { error: `Path parameter ${name} cannot be "${value}".` }; - // The API takes username~name; apify-client swaps the first slash of a resource ID the same way. - const apiValue = RESOURCE_ID_PARAMETERS.has(name) ? value.replace('/', '~') : value; - path = path.replace(`{${name}}`, encodeURIComponent(apiValue)); - } - return { path }; + const unavailableMessage = formatUnavailableMessage(operation, path); + return unavailableMessage ? { error: unavailableMessage } : { operation }; } /** Query values the API reads as true. */ @@ -138,7 +208,7 @@ export function validateQueryParams( const unknownNames = Object.keys(query).filter((name) => !declaredNames.has(name)); if (unknownNames.length > 0) { return ( - `${operation.operationId} does not take the query parameter ${unknownNames.join(', ')}. ` + + `${operation.method} ${operation.path} does not take the query parameter ${unknownNames.join(', ')}. ` + `It takes: ${[...declaredNames].join(', ') || 'none'}.` ); } @@ -180,7 +250,7 @@ function toPlainRequestError(error: unknown): Error { } /** - * Sends one request to a filled-in operation path and returns the response body. + * Sends one request to a matched operation's path and returns the response body. * * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one * attempt and no retries, since a retried write could apply twice, and apify-client would retry the @@ -222,7 +292,6 @@ export async function callApiOperation(params: { // apify-client parses JSON and text bodies; a binary body stays a Buffer and is not returned. const isBinary = Buffer.isBuffer(response.data); const structuredContent = { - operationId: operation.operationId, method: operation.method, path, statusCode: response.status, diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index a9fa74cc7..19cb806e7 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -26,13 +26,16 @@ const apifyApiSearchArgs = z.object({ function buildDescription({ hasTool }: ToolDescriptionContext): string { const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); const nextSteps = [ - hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS}`, + hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS} by its path`, callTools.length > 0 && `call it with ${callTools.join(' or ')}`, ].filter(Boolean); const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; return dedent` - Search the Apify API reference for operations by keywords. Returns each operation's ID, method, - path, summary, and access: read, write, or unavailable with the reason the API tools do not call it.${nextStepsSentence} + Search the Apify API reference for operations by keywords. Returns each operation's method, path + template, summary, link to the API reference, and access: read, write, or unavailable with the + reason the API tools do not call it.${nextStepsSentence} + To call an operation, fill its path template with real values: /v2/datasets/{datasetId}/items + becomes /v2/datasets/abc/items, and a name is written username~name. Prefer a dedicated Apify tool when one does what the user asks. USAGE: @@ -68,12 +71,12 @@ export const apifyApiSearch: ToolEntry = Object.freeze({ const parsed = apifyApiSearchArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); const operations = searchApiOperations(index, parsed.query, parsed.limit).map((operation) => ({ - operationId: operation.operationId, method: operation.method, path: operation.path, summary: operation.summary, access: operation.access, ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), + docsUrl: operation.docsUrl, })); const result = { operations }; const summary = diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 08c5806be..5fe8ea039 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -13,8 +13,11 @@ export const API_ACCESS = { export type ApiAccess = (typeof API_ACCESS)[keyof typeof API_ACCESS]; /** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ -const INDEXED_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; -type ApiMethod = (typeof INDEXED_METHODS)[number]; +export const API_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; +export type ApiMethod = (typeof API_METHODS)[number]; + +/** The API reference has one page per operation, at the operation ID in kebab case. */ +const API_DOCS_BASE_URL = 'https://docs.apify.com/api/v2'; export type ApiParameter = { name: string; @@ -25,12 +28,15 @@ export type ApiParameter = { }; export type ApiOperation = { + /** The spec's key for the operation. Internal: the tools find operations by method and path. */ operationId: string; method: ApiMethod; /** Path template, for example `/v2/actors/{actorId}`. */ path: string; summary: string; description: string; + /** The operation's page in the API reference. */ + docsUrl: string; tags: string[]; /** Path and query parameters; header parameters are left out, the tools never send them. */ parameters: ApiParameter[]; @@ -47,7 +53,7 @@ const DELETE_REASON = 'Deletion cannot be undone, so the API tools do not delete. The user can delete it in Apify Console.'; const SYNC_RUN_REASON = 'It waits up to 300 seconds for the run to finish, longer than MCP clients wait for a tool call. ' + - 'Start the run with actors_runs_post or actorTask_runs_post instead.'; + 'Start the run with POST /v2/actors/{actorId}/runs or POST /v2/actor-tasks/{actorTaskId}/runs instead.'; const METAMORPH_REASON = 'It turns the run into a run of another Actor, which cannot be undone. Only the Actor itself metamorphs its run.'; @@ -160,6 +166,15 @@ function extractTopLevelPropertyNames(schema: unknown): Set { return names; } +/** Lowercase words of a text, splitting camelCase, snake_case, kebab-case, and paths. */ +function splitWords(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + function resolveAccess( method: ApiMethod, path: string, @@ -211,7 +226,7 @@ export function buildApiOperationIndex(spec: unknown): Map const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; - for (const method of INDEXED_METHODS) { + for (const method of API_METHODS) { const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); if (!parsed.success || parsed.data.deprecated) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; @@ -224,6 +239,7 @@ export function buildApiOperationIndex(spec: unknown): Map path, summary: summary ?? '', description: description ?? '', + docsUrl: `${API_DOCS_BASE_URL}/${splitWords(operationId).join('-')}`, tags: tags ?? [], parameters: parseParameters(parameters, spec), ...(body && { requestBody: body }), @@ -278,15 +294,6 @@ const SEARCH_STOP_WORDS: ReadonlySet = new Set([ 'with', ]); -/** Lowercase words of a text, splitting camelCase, snake_case, kebab-case, and paths. */ -function splitWords(text: string): string[] { - return text - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean); -} - /** Verbs for what each method does, so a verb the summary does not use, such as rename, still counts. */ const METHOD_VERBS: Record = { GET: ['get', 'list', 'read'], diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 5bf4e05b9..9d917c27b 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -899,27 +899,24 @@ export const apifyApiSearchOutputSchema = { items: { type: 'object' as const, properties: { - operationId: { type: 'string', description: 'Operation ID' }, method: { type: 'string', description: 'HTTP method' }, - path: { type: 'string', description: 'Path template' }, + path: { type: 'string', description: 'Path template; each {name} is a path parameter' }, summary: { type: 'string', description: 'What the operation does' }, access: apiAccessProperty, unavailableReason: apiUnavailableReasonProperty, + docsUrl: { type: 'string', description: "The operation's page in the API reference" }, }, - required: ['operationId', 'method', 'path', 'summary', 'access'], + required: ['method', 'path', 'summary', 'access', 'docsUrl'], }, }, }, required: ['operations'], }; -/** - * Schema for one operation's details (apify-api-details). - */ -export const apifyApiDetailsOutputSchema = { +/** One operation in the apify-api-details response. */ +const apiOperationDetailsSchema = { type: 'object' as const, properties: { - operationId: { type: 'string', description: 'Operation ID' }, method: { type: 'string', description: 'HTTP method' }, path: { type: 'string', description: 'Path template; each {name} is a path parameter' }, summary: { type: 'string', description: 'What the operation does' }, @@ -956,7 +953,22 @@ export const apifyApiDetailsOutputSchema = { description: 'Body fields the API tools refuse to set for this operation', }, }, - required: ['operationId', 'method', 'path', 'summary', 'description', 'access', 'parameters', 'refusedBodyFields'], + required: ['method', 'path', 'summary', 'description', 'access', 'parameters', 'refusedBodyFields'], +}; + +/** + * Schema for the operations on one path (apify-api-details). + */ +export const apifyApiDetailsOutputSchema = { + type: 'object' as const, + properties: { + operations: { + type: 'array' as const, + items: apiOperationDetailsSchema, + description: 'The operations on the path, or only the one with the given method', + }, + }, + required: ['operations'], }; /** @@ -965,12 +977,11 @@ export const apifyApiDetailsOutputSchema = { export const apifyApiCallOutputSchema = { type: 'object' as const, properties: { - operationId: { type: 'string', description: 'Operation ID' }, method: { type: 'string', description: 'HTTP method' }, - path: { type: 'string', description: 'Request path, with the path parameters filled in' }, + path: { type: 'string', description: 'Request path as it was sent' }, statusCode: { type: 'integer', description: 'HTTP status code' }, contentType: { type: 'string', description: 'Content-Type of the response' }, data: { description: 'Response body: parsed JSON, text, or null for an empty or binary body' }, }, - required: ['operationId', 'method', 'path', 'statusCode', 'data'], + required: ['method', 'path', 'statusCode', 'data'], }; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 86e608459..06e7146aa 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -171,6 +171,15 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'actorTaskId', in: 'path', required: true, schema: { type: 'string' } }], }, }, + // The last run; a path with a run ID here matches nothing, since its operation is deprecated. + '/v2/actors/{actorId}/runs/last': { + get: { + operationId: 'actor_runs_last_get', + summary: 'Get last run', + tags: ['Last Actor run'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, + }, '/v2/actors/{actorId}/runs/{runId}': { get: { operationId: 'actors_run_get', @@ -210,6 +219,17 @@ export const API_SPEC_FIXTURE = { }, }, }, + '/v2/users/{userId}': { + get: { + operationId: 'user_get', + summary: 'Get public user data', + tags: ['Users/Public information'], + parameters: [{ name: 'userId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, + '/v2/users/me': { + get: { operationId: 'users_me_get', summary: 'Get private user data', tags: ['Users/Private data'] }, + }, '/v2/users/me/limits': { put: { operationId: 'users_me_limits_put', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index ecffae7e5..2f9d0bf5c 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -5,9 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; -import { buildRequestPath, redactApiCallArgs, validateQueryParams } from '../../src/tools/api/apify_api_request.js'; +import { + parseApiPath, + redactApiCallArgs, + resolvePathOperations, + validateQueryParams, +} from '../../src/tools/api/apify_api_request.js'; import { apifyApiSearch } from '../../src/tools/api/apify_api_search.js'; import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; +import type { ApiOperation } from '../../src/tools/api/apify_api_spec.js'; import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; import { apifyApiCallOutputSchema, @@ -59,31 +65,83 @@ beforeEach(() => { requestMock.mockReset(); }); -describe('buildRequestPath()', () => { - const datasetItems = INDEX.get('dataset_items_get')!; +describe('parseApiPath()', () => { + it.each([ + ['/v2/datasets/john~my data/items', ['datasets', 'john~my%20data', 'items']], + ['/v2/datasets/john~my%20data/items', ['datasets', 'john~my%20data', 'items']], + ['/v2/datasets/john%7Emy-data', ['datasets', 'john~my-data']], + ['/v2/key-value-stores/store-1/records/a%2Fb', ['key-value-stores', 'store-1', 'records', 'a%2Fb']], + ['/v2/actor-runs/%3F', ['actor-runs', '%3F']], + ])('normalizes %s, so a value works encoded or not', (path, segments) => { + expect(parseApiPath(path)).toEqual({ segments }); + }); + + it.each([ + ['/datasets/abc', 'The path must start with /v2/'], + ['https://api.apify.com/v2/datasets', 'The path must start with /v2/'], + ['/v2/datasets?limit=1', 'The path cannot hold ? or #. Pass query parameters in query.'], + ['/v2/datasets#items', 'The path cannot hold ? or #.'], + ['/v2/datasets/', 'The path /v2/datasets/ has an empty segment.'], + ['/v2//datasets', 'has an empty segment'], + ['/v2/datasets/../users/me', 'The path cannot have a .. segment.'], + ['/v2/datasets/%2E%2E/users/me', 'The path cannot have a .. segment.'], + ['/v2/datasets/./items', 'The path cannot have a . segment.'], + ['/v2/datasets/%2e/items', 'The path cannot have a . segment.'], + ['/v2/datasets/100%', 'The path segment 100% is not valid URL encoding.'], + ['/v2/datasets/{datasetId}/items', 'Replace {datasetId} in the path with its value.'], + ])('refuses %s', (path, reason) => { + const result = parseApiPath(path); + expect('error' in result && result.error).toContain(reason); + }); - it('fills and encodes the path parameters, turning username/name into username~name', () => { - expect(buildRequestPath(datasetItems, { datasetId: 'john/my data' })).toEqual({ - path: '/v2/datasets/john~my%20data/items', + it('takes a template only when asked to', () => { + expect(parseApiPath('/v2/datasets/{datasetId}', true)).toEqual({ segments: ['datasets', '%7BdatasetId%7D'] }); + }); +}); + +describe('resolvePathOperations()', () => { + function resolveIds(path: string): string[] { + const result = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); + return 'error' in result ? [] : result.operations.map((operation) => operation.operationId); + } + + it('returns every method on the matched template, with the path as it is sent', () => { + const result = resolvePathOperations({ index: INDEX, path: '/v2/datasets/my data', loadedToolNames: [] }); + + expect(result).toEqual({ + path: '/v2/datasets/my%20data', + operations: [INDEX.get('dataset_get'), INDEX.get('dataset_put'), INDEX.get('dataset_delete')], }); - expect(buildRequestPath(INDEX.get('webhooks_get')!)).toEqual({ path: '/v2/webhooks' }); }); - it('sends a value that is not a resource ID, such as a record key, as it is', () => { - expect( - buildRequestPath(INDEX.get('keyValueStore_record_put')!, { storeId: 'john/store', recordKey: 'a/b' }), - ).toEqual({ path: '/v2/key-value-stores/john~store/records/a%2Fb' }); + it('matches a literal segment only to itself and prefers the template with more literal segments', () => { + expect(resolveIds('/v2/users/me')).toEqual(['users_me_get']); + expect(resolveIds('/v2/users/abc')).toEqual(['user_get']); + expect(resolveIds('/v2/actors/abc/runs/last')).toEqual(['actor_runs_last_get']); + expect(resolveIds('/v2/actors/abc/runs/run-1')).toEqual([]); }); - it.each([ - [{}, 'Missing path parameter datasetId'], - [{ datasetId: '' }, 'Missing path parameter datasetId'], - [{ datasetId: 'abc', runId: 'x' }, 'has no path parameter runId'], - [{ datasetId: '..' }, 'cannot be ".."'], - [{ datasetId: '.' }, 'cannot be "."'], - ])('refuses %j', (pathParams, reason) => { - const result = buildRequestPath(datasetItems, pathParams); - expect('error' in result && result.error).toContain(reason); + it('matches a concrete path built from each template back to its own operation', () => { + for (const operation of INDEX.values()) { + const path = operation.path.replace(/\{[^}]+\}/g, 'value-1'); + const result = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); + + const { operations } = result as { operations: ApiOperation[] }; + expect(operations).toContainEqual(operation); + expect(operations.filter((matched) => matched.path !== operation.path)).toEqual([]); + } + }); + + it('explains username~name on no match and names the search tool only when the session has it', () => { + const path = '/v2/actors/john/my-actor'; + const withSearch = resolvePathOperations({ index: INDEX, path, loadedToolNames: [HELPER_TOOLS.API_SEARCH] }); + const withoutSearch = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); + + const message = + 'No Apify API operation matches the path /v2/actors/john/my-actor. Write the path with its values ' + + 'in it; a name is written username~name, as in /v2/actors/john~my-actor.'; + expect(withSearch).toEqual({ error: `${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.` }); + expect(withoutSearch).toEqual({ error: message }); }); }); @@ -94,7 +152,7 @@ describe('validateQueryParams()', () => { it('refuses a parameter the operation does not declare, such as a token', () => { expect(validateQueryParams(INDEX.get('dataset_items_get')!, { format: 'json', token: 'secret' })).toBe( - 'dataset_items_get does not take the query parameter token. It takes: limit, format.', + 'GET /v2/datasets/{datasetId}/items does not take the query parameter token. It takes: limit, format.', ); }); @@ -123,19 +181,19 @@ describe('validateQueryParams()', () => { }); describe('apify-api-search', () => { - it('returns the matching operations with their access', async () => { + it('returns the matching operations by method and path, with their access and docs page', async () => { const result = await callTool(apifyApiSearch, { query: 'delete dataset', limit: 1 }); expectSchemaConformingStructuredContent(result, apifyApiSearchOutputSchema); expect(result.structuredContent).toEqual({ operations: [ { - operationId: 'dataset_delete', method: 'DELETE', path: '/v2/datasets/{datasetId}', summary: 'Delete dataset', access: 'unavailable', unavailableReason: expect.stringContaining('Deletion cannot be undone'), + docsUrl: 'https://docs.apify.com/api/v2/dataset-delete', }, ], }); @@ -150,41 +208,61 @@ describe('apify-api-search', () => { }); describe('apify-api-details', () => { - it('returns the parameters, body schema, and refused fields', async () => { - const result = await callTool(apifyApiDetails, { operationId: 'actor_put' }); + it('returns every operation on a real path when no method is given', async () => { + const result = await callTool(apifyApiDetails, { path: '/v2/datasets/abc' }); expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); - expect(result.structuredContent).toMatchObject({ - operationId: 'actor_put', - method: 'PUT', - path: '/v2/actors/{actorId}', - access: 'write', - parameters: [{ name: 'actorId', in: 'path', isRequired: true }], - requestBody: { isRequired: false }, - refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel'], + const { operations } = result.structuredContent as { operations: { method: string; path: string }[] }; + expect(operations.map(({ method, path }) => `${method} ${path}`)).toEqual([ + 'GET /v2/datasets/{datasetId}', + 'PUT /v2/datasets/{datasetId}', + 'DELETE /v2/datasets/{datasetId}', + ]); + expect(result.content[1].text).toBe('/v2/datasets/{datasetId}: GET (read), PUT (write), DELETE (unavailable).'); + }); + + it('returns only the operation with the method, for a path template too', async () => { + const result = await callTool(apifyApiDetails, { path: '/v2/actors/{actorId}', method: 'PUT' }); + + expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); + expect(result.structuredContent).toEqual({ + operations: [ + expect.objectContaining({ + method: 'PUT', + path: '/v2/actors/{actorId}', + access: 'write', + parameters: [expect.objectContaining({ name: 'actorId', in: 'path', isRequired: true })], + requestBody: expect.objectContaining({ isRequired: false }), + refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel'], + }), + ], }); }); - it('names the search tool on an unknown operation only when the session has it', async () => { - const withSearch = await callTool(apifyApiDetails, { operationId: 'nope' }); - const withoutSearch = await callTool(apifyApiDetails, { operationId: 'nope' }, [HELPER_TOOLS.API_DETAILS]); + it('refuses a method the path does not have and lists the ones it has', async () => { + const result = await callTool(apifyApiDetails, { path: '/v2/actors/abc', method: 'GET' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe('The path /v2/actors/abc has no GET operation; it matches method PUT.'); + }); + + it('names the search tool on an unknown path only when the session has it', async () => { + const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); + const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); expectSoftFailInvalidInput(withSearch); - expect(withSearch.content[0].text).toBe( - `API operation nope not found. Find it with ${HELPER_TOOLS.API_SEARCH}.`, - ); - expect(withoutSearch.content[0].text).toBe('API operation nope not found. Operation IDs are case-sensitive.'); + expect(withSearch.content[0].text).toContain(`Find the path with ${HELPER_TOOLS.API_SEARCH}.`); + expect(withoutSearch.content[0].text).not.toContain(HELPER_TOOLS.API_SEARCH); }); }); describe('apify-api-read', () => { - it('sends one GET to the filled-in path and returns the body as the API sends it', async () => { + it('sends one GET to the path and returns the body as the API sends it', async () => { const body = { data: { total: 1, items: [{ id: 'abc' }] } }; requestMock.mockResolvedValue(mockResponse(200, body)); const result = await callTool(apifyApiRead, { - operationId: 'dataset_items_get', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc/items', query: { format: 'json', limit: 1 }, }); @@ -198,7 +276,6 @@ describe('apify-api-read', () => { }); expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); expect(result.structuredContent).toEqual({ - operationId: 'dataset_items_get', method: 'GET', path: '/v2/datasets/abc/items', statusCode: 200, @@ -208,32 +285,45 @@ describe('apify-api-read', () => { expect(result.content[1].text).toBe('GET /v2/datasets/abc/items returned HTTP 200.'); }); + it('sends a value with a space encoded and keeps ~ as it is', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); + + const result = await callTool(apifyApiRead, { path: '/v2/datasets/john~my data' }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ url: `${BASE_URL}/datasets/john~my%20data` }), + ); + expect(result.structuredContent).toMatchObject({ path: '/v2/datasets/john~my%20data' }); + }); + it.each([ [ - 'dataset_put', - 'dataset_put is a PUT operation with write access; this tool has read access. ' + + '/v2/actor-runs/run-1/abort', + 'The path /v2/actor-runs/run-1/abort matches method POST, not GET; this tool sends only GET. ' + 'No tool in this session has write access.', ], - ['actor_runSync_get', 'The API tools do not call actor_runSync_get. It waits up to 300 seconds'], - ['nope', 'API operation nope not found.'], - ])('refuses %s without a request', async (operationId, reason) => { - const result = await callTool(apifyApiRead, { operationId, pathParams: { datasetId: 'abc', actorId: 'a' } }); + [ + '/v2/actors/abc/run-sync', + 'The API tools do not call GET /v2/actors/abc/run-sync. It waits up to 300 seconds', + ], + ['/v2/nope', 'No Apify API operation matches the path /v2/nope.'], + ['/v2/datasets/{datasetId}', 'Replace {datasetId} in the path with its value.'], + ['/v2/datasets/abc?limit=1', 'Pass query parameters in query.'], + ])('refuses %s without a request', async (path, reason) => { + const result = await callTool(apifyApiRead, { path }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toContain(reason); expect(requestMock).not.toHaveBeenCalled(); }); - it('refuses bad path or query parameters without a request', async () => { - const badPath = await callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: '..' } }); - const badQuery = await callTool(apifyApiRead, { - operationId: 'dataset_get', - pathParams: { datasetId: 'abc' }, - query: { token: 'secret' }, - }); + it('refuses an undeclared query parameter without a request', async () => { + const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc', query: { token: 'secret' } }); - expectSoftFailInvalidInput(badPath); - expectSoftFailInvalidInput(badQuery); + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + 'GET /v2/datasets/{datasetId} does not take the query parameter token. It takes: none.', + ); expect(requestMock).not.toHaveBeenCalled(); }); @@ -242,7 +332,7 @@ describe('apify-api-read', () => { mockResponse(404, { error: { type: 'record-not-found', message: 'Dataset was not found' } }), ); - const call = callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + const call = callTool(apifyApiRead, { path: '/v2/datasets/abc' }); await expect(call).rejects.toBeInstanceOf(ApifyApiError); await expect(call).rejects.toMatchObject({ @@ -257,11 +347,7 @@ describe('apify-api-read', () => { new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), ); - const result = await callTool(apifyApiRead, { - operationId: 'dataset_items_get', - pathParams: { datasetId: 'abc' }, - query: { format: 'json' }, - }); + const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc/items', query: { format: 'json' } }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toContain(`larger than ${MAX_INLINE_BYTES} bytes`); @@ -272,10 +358,7 @@ describe('apify-api-read', () => { const config = { headers: new AxiosHeaders({ Authorization: 'Bearer secret-token' }) }; requestMock.mockRejectedValue(new AxiosError('socket hang up', 'ECONNRESET', config)); - const error = await callTool(apifyApiRead, { - operationId: 'dataset_get', - pathParams: { datasetId: 'abc' }, - }).catch((thrown: unknown) => thrown); + const error = await callTool(apifyApiRead, { path: '/v2/datasets/abc' }).catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(Error); expect(error).not.toBeInstanceOf(AxiosError); @@ -287,10 +370,7 @@ describe('apify-api-read', () => { const controller = new AbortController(); controller.abort(); requestMock.mockRejectedValue(new CanceledError()); - const context = stubToolCallContext( - { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, - stubClient, - ); + const context = stubToolCallContext({ path: '/v2/datasets/abc' }, stubClient); context.signal = controller.signal; await expect((apifyApiRead as HelperTool).call(context)).resolves.toEqual({}); @@ -299,7 +379,7 @@ describe('apify-api-read', () => { it('describes a binary body instead of returning it', async () => { requestMock.mockResolvedValue(mockResponse(200, Buffer.from([1, 2, 3]), 'application/zip')); - const result = await callTool(apifyApiRead, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }); + const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc' }); expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); expect(result.structuredContent).toMatchObject({ contentType: 'application/zip', data: null }); @@ -310,15 +390,13 @@ describe('apify-api-read', () => { it('logs a storage signature, webhooks, or an undeclared token only as redacted', () => { const args = { - operationId: 'dataset_items_get', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc/items', query: { format: 'json', signature: 'sig-secret', webhooks: 'W3t9XQ==', token: 'token-secret' }, }; expect((apifyApiRead as HelperTool).redactArgs).toBe(redactApiCallArgs); expect(redactApiCallArgs(args)).toEqual({ - operationId: 'dataset_items_get', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc/items', query: { format: 'json', signature: '[REDACTED]', webhooks: '[REDACTED]', token: '[REDACTED]' }, }); // The tool itself still gets the real values. @@ -326,13 +404,14 @@ describe('apify-api-read', () => { }); it('logs only the declared arguments and a body only as redacted', () => { - const logged = redactApiCallArgs({ operationId: 'dataset_get', body: { value: 'secret' }, extra: 'secret' }); - - expect(logged).toEqual({ - operationId: 'dataset_get', - pathParams: undefined, - query: undefined, - body: '[REDACTED]', + const logged = redactApiCallArgs({ + path: '/v2/datasets/abc', + method: 'PUT', + body: { value: 'secret' }, + extra: 'secret', }); + + expect(logged).toEqual({ path: '/v2/datasets/abc', method: 'PUT', query: undefined, body: '[REDACTED]' }); + expect(JSON.stringify(logged)).not.toContain('secret'); }); }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index b2bcc6d02..6222eef93 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(23); + expect(index.size).toBe(26); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -79,6 +79,14 @@ describe('buildApiOperationIndex()', () => { expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); }); + it('links each operation to its page in the API reference, at the operation ID in kebab case', () => { + expect(index.get('actorRun_abort_post')?.docsUrl).toBe('https://docs.apify.com/api/v2/actor-run-abort-post'); + expect(index.get('PostChargeRun')?.docsUrl).toBe('https://docs.apify.com/api/v2/post-charge-run'); + expect(index.get('keyValueStore_record_put')?.docsUrl).toBe( + 'https://docs.apify.com/api/v2/key-value-store-record-put', + ); + }); + it('returns an empty index for a document without paths', () => { expect(buildApiOperationIndex({}).size).toBe(0); expect(buildApiOperationIndex('not a spec').size).toBe(0); From 223ba4579cc8222ff3bffb10a1ec5b682ef9e5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 28 Sep 2026 23:07:55 +0200 Subject: [PATCH 013/103] feat: Call apify-api-write by path, with an optional method apify-api-write now takes {path, method?, query?, body?}, like the read tool, instead of an operation ID and path parameters. - Without a method, the path's only method is used. A path with several methods is refused: "The path matches methods GET, PUT, and DELETE; specify which one to call the endpoint with." - A GET points to apify-api-read when the session has it. DELETE and the other unavailable operations give their reason. - apify-api-read names apify-api-write for a path without GET only when the session has it, and gives the reason when the path has no operation the tools call. - Body and query refusals name the method and path template. The envVars refusal names the single-variable env var paths. - The oversize write result drops operationId, like the other results. --- src/tools/api/apify_api_read.ts | 2 +- src/tools/api/apify_api_request.ts | 62 ++++++++-- src/tools/api/apify_api_write.ts | 42 +++++-- tests/unit/tools.apify_api.test.ts | 184 ++++++++++++++++------------- 4 files changed, 187 insertions(+), 103 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 84e1f2a34..a292fe60c 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -71,7 +71,7 @@ export const apifyApiRead: ToolEntry = Object.freeze({ loadedToolNames: toolArgs.loadedToolNames, }); if ('error' in matched) return respondUserError(matched.error); - const resolved = resolveReadOperation(matched.path, matched.operations); + const resolved = resolveReadOperation(matched.path, matched.operations, toolArgs.loadedToolNames); if ('error' in resolved) return respondUserError(resolved.error); const { operation } = resolved; diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index d85363a2b..c355af001 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -183,19 +183,62 @@ export function formatUnavailableMessage(operation: ApiOperation, path: string): export function resolveReadOperation( path: string, operations: readonly ApiOperation[], + loadedToolNames: readonly string[], ): { operation: ApiOperation } | { error: string } { const operation = operations.find((candidate) => candidate.method === 'GET'); if (!operation) { return { error: `The path ${path} matches ${formatMatchedMethods(operations)}, not GET; this tool sends only GET. ` + - 'No tool in this session has write access.', + formatReadNextStep(operations, loadedToolNames), }; } const unavailableMessage = formatUnavailableMessage(operation, path); return unavailableMessage ? { error: unavailableMessage } : { operation }; } +/** Where to call a path without GET: the write tool when it may, else why not. */ +function formatReadNextStep(operations: readonly ApiOperation[], loadedToolNames: readonly string[]): string { + // Without GET, each operation has write access or is unavailable. + const hasWriteOperation = operations.some((candidate) => candidate.access === API_ACCESS.WRITE); + if (!hasWriteOperation) return `The API tools do not call it. ${operations[0].unavailableReason}`; + return loadedToolNames.includes(HELPER_TOOLS.API_WRITE) + ? `Call it with ${HELPER_TOOLS.API_WRITE}.` + : 'No tool in this session has write access.'; +} + +/** + * The POST or PUT operation on the matched path, or why the write tool may not call it. Without a + * method, the path's only method is used; a path with several needs the method, since guessing + * between, say, a create and a delete is not safe. + */ +export function resolveWriteOperation(params: { + path: string; + operations: readonly ApiOperation[]; + method?: string; + loadedToolNames: readonly string[]; +}): { operation: ApiOperation } | { error: string } { + const { path, operations, method, loadedToolNames } = params; + if (method === undefined && operations.length > 1) { + return { + error: `The path matches methods ${formatMethodList(operations)}; specify which one to call the endpoint with.`, + }; + } + const resolved = + method === undefined ? { operation: operations[0] } : resolveMethodOperation(path, operations, method); + if ('error' in resolved) return resolved; + const { operation } = resolved; + const unavailableMessage = formatUnavailableMessage(operation, path); + if (unavailableMessage) return { error: unavailableMessage }; + if (operation.access === API_ACCESS.READ) { + const next = loadedToolNames.includes(HELPER_TOOLS.API_READ) + ? `Call it with ${HELPER_TOOLS.API_READ}.` + : 'No tool in this session has read access.'; + return { error: `${operation.method} ${path} has read access; this tool has write access. ${next}` }; + } + return { operation }; +} + /** Query values the API reads as true. */ const TRUE_QUERY_VALUES: ReadonlySet = new Set(['true', '1']); @@ -248,15 +291,17 @@ export function validateQueryParams( /** Checks the body against the operation; returns the reason on failure. */ export function validateRequestBody(operation: ApiOperation, body: unknown): string | undefined { if (!operation.requestBody) { - return body === undefined ? undefined : `${operation.operationId} takes no request body.`; + return body === undefined ? undefined : `${operation.method} ${operation.path} takes no request body.`; } if (body === undefined) { - return operation.requestBody.isRequired ? `${operation.operationId} needs a request body.` : undefined; + return operation.requestBody.isRequired + ? `${operation.method} ${operation.path} needs a request body.` + : undefined; } // A JSON-encoded string would reach the API as a string, not as the object, and skip the checks below. if (typeof body === 'string' || body === null) { return ( - `Pass the body of ${operation.operationId} as a JSON object or array, not as ` + + `Pass the body of ${operation.method} ${operation.path} as a JSON object or array, not as ` + `${body === null ? 'null' : 'a string'}.` ); } @@ -279,11 +324,13 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str 'environment variables, which dedicated source tools or Apify Console change.', ); } - // Operation IDs, not tool names: this same tool calls them, and each changes one variable. + // Paths, not tool names: this same tool calls them, and each changes one variable. if (sourceFields.includes('envVars')) { sentences.push( - 'To add or change one environment variable, call actor_version_envVars_post or ' + - 'actor_version_envVar_put; each leaves the other variables as they are.', + 'To add or change one environment variable, call POST ' + + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars or PUT ' + + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}; each leaves the other ' + + 'variables as they are.', ); } // Sending the rest of an otherwise empty body would succeed and change nothing. @@ -368,7 +415,6 @@ export async function callApiOperation(params: { const statusCode = readAbortedResponseStatus(error); if (operation.method !== 'GET' && statusCode !== undefined && statusCode < 300) { const structuredContent = { - operationId: operation.operationId, method: operation.method, path, statusCode, diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 028773d40..2888b0ac9 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -9,17 +9,25 @@ import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; import { apiCallArgsShape, - buildRequestPath, callApiOperation, redactApiCallArgs, - resolveOperationToCall, + resolvePathOperations, + resolveWriteOperation, validateQueryParams, validateRequestBody, } from './apify_api_request.js'; -import { API_ACCESS, fetchApiOperationIndex } from './apify_api_spec.js'; +import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiWriteArgs = z.object({ - ...apiCallArgsShape, + path: apiCallArgsShape.path, + method: z + .enum(API_METHODS) + .optional() + .describe( + 'HTTP method: POST or PUT. Required when the path has several methods, for example GET, PUT, and ' + + 'DELETE; omit it when the path has one.', + ), + query: apiCallArgsShape.query, body: z .unknown() .optional() @@ -30,18 +38,22 @@ const apifyApiWriteArgs = z.object({ }); function buildDescription({ hasTool }: ToolDescriptionContext): string { + const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? `\nFind the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) ? `\nGet the operation's parameters and body schema first with ${HELPER_TOOLS.API_DETAILS}.` : ''; return dedent` - Call one Apify API operation with write access (POST or PUT), by its operation ID. The server builds - the URL from the operation's path and pathParams and adds the API token: never pass a URL or a token.${getParameters} + Send a POST or PUT request to the Apify API at a path with its values in it, such as + /v2/datasets/abc. Give the method when the path has several. The server adds the host and the API + token: never pass a URL or a token.${findPath}${getParameters} The request is sent once and applies at once. Refused: deletions, synchronous runs, metamorphs, spending limits, run charging, and creating Actor versions; fields that publish an Actor or task, change its pricing or permissions, or change who can read a storage; and fields that set an Actor's source, versions, or environment variables, which dedicated source tools or Apify Console change. Returns the response body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. + Example call: {"path": "/v2/datasets/abc", "method": "PUT", "body": {"name": "leads-2026"}} + USAGE: - Use to change something no dedicated tool changes, such as a dataset's name or a webhook. - Change only what the user asked for. @@ -53,7 +65,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Calls one POST or PUT operation of the published Apify API spec, https://docs.apify.com/api/v2. + * Sends a POST or PUT to a path of the published Apify API spec, https://docs.apify.com/api/v2. */ export const apifyApiWrite: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -77,24 +89,28 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); - const resolved = resolveOperationToCall({ + const matched = resolvePathOperations({ index, - operationId: parsed.operationId, - access: API_ACCESS.WRITE, + path: parsed.path, + loadedToolNames: toolArgs.loadedToolNames, + }); + if ('error' in matched) return respondUserError(matched.error); + const resolved = resolveWriteOperation({ + path: matched.path, + operations: matched.operations, + method: parsed.method, loadedToolNames: toolArgs.loadedToolNames, }); if ('error' in resolved) return respondUserError(resolved.error); const { operation } = resolved; - const request = buildRequestPath(operation, parsed.pathParams); - if ('error' in request) return respondUserError(request.error); const inputError = validateQueryParams(operation, parsed.query) ?? validateRequestBody(operation, parsed.body); if (inputError) return respondUserError(inputError); return callApiOperation({ client: toolArgs.apifyClient, operation, - path: request.path, + path: matched.path, query: parsed.query, body: parsed.body, signal: toolArgs.signal, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 128b9803b..df5728a90 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -302,7 +302,11 @@ describe('apify-api-read', () => { [ '/v2/actor-runs/run-1/abort', 'The path /v2/actor-runs/run-1/abort matches method POST, not GET; this tool sends only GET. ' + - 'No tool in this session has write access.', + `Call it with ${HELPER_TOOLS.API_WRITE}.`, + ], + [ + '/v2/actor-runs/run-1/metamorph', + 'matches method POST, not GET; this tool sends only GET. The API tools do not call it. It turns the run', ], [ '/v2/actors/abc/run-sync', @@ -319,6 +323,19 @@ describe('apify-api-read', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('names the write tool for a path without GET only when the session has it', async () => { + const args = { path: '/v2/actors/john~my-actor/versions/0.1' }; + const withWrite = await callTool(apifyApiRead, args); + const withoutWrite = await callTool(apifyApiRead, args, [HELPER_TOOLS.API_READ]); + + expect(withWrite.content[0].text).toBe( + 'The path /v2/actors/john~my-actor/versions/0.1 matches methods POST and PUT, not GET; this tool ' + + `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE}.`, + ); + expect(withoutWrite.content[0].text).not.toContain(HELPER_TOOLS.API_WRITE); + expect(withoutWrite.content[0].text).toContain('No tool in this session has write access.'); + }); + it('refuses an undeclared query parameter without a request', async () => { const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc', query: { token: 'secret' } }); @@ -426,8 +443,8 @@ describe('apify-api-write', () => { requestMock.mockResolvedValue(mockResponse(200, body)); const result = await callTool(apifyApiWrite, { - operationId: 'dataset_put', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc', + method: 'PUT', body: { name: 'leads-2026' }, }); @@ -443,10 +460,10 @@ describe('apify-api-write', () => { expect(result.structuredContent).toMatchObject({ method: 'PUT', path: '/v2/datasets/abc', data: body }); }); - it('sends an operation without a body when none is given', async () => { + it("uses the path's only method when none is given, and sends no body when none is given", async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { id: 'run-1', status: 'ABORTING' } })); - await callTool(apifyApiWrite, { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' } }); + await callTool(apifyApiWrite, { path: '/v2/actor-runs/run-1/abort' }); expect(requestMock).toHaveBeenCalledWith({ ...REQUEST_BASE, @@ -457,9 +474,9 @@ describe('apify-api-write', () => { it('sends the body as application/json through the real apify-client axios instance', async () => { const client = new ApifyClient({ token: 'test-token', baseUrl: 'https://api.apify.com' }); - const sent: { data?: unknown; contentType?: unknown }[] = []; + const sent: { url?: string; data?: unknown; contentType?: unknown }[] = []; client.httpClient.axios.defaults.adapter = async (config) => { - sent.push({ data: config.data, contentType: config.headers.get('Content-Type') }); + sent.push({ url: config.url, data: config.data, contentType: config.headers.get('Content-Type') }); return { status: 201, statusText: 'Created', @@ -470,10 +487,7 @@ describe('apify-api-write', () => { }; }; const call = async (body: unknown) => { - const context = stubToolCallContext( - { operationId: 'keyValueStore_record_put', pathParams: { storeId: 's', recordKey: 'K' }, body }, - client, - ); + const context = stubToolCallContext({ path: '/v2/key-value-stores/s/records/a%2FK', body }, client); return (await (apifyApiWrite as HelperTool).call(context)) as TextToolResult; }; @@ -482,104 +496,103 @@ describe('apify-api-write', () => { expect(results.map((result) => result.structuredContent)).toEqual( Array(3).fill(expect.objectContaining({ statusCode: 201, data: { data: {} } })), ); + const url = 'https://api.apify.com/v2/key-value-stores/s/records/a%2FK'; expect(sent).toEqual([ - { data: '{"name":"x"}', contentType: 'application/json' }, - { data: '42', contentType: 'application/json' }, - { data: '[1,2]', contentType: 'application/json' }, + { url, data: '{"name":"x"}', contentType: 'application/json' }, + { url, data: '42', contentType: 'application/json' }, + { url, data: '[1,2]', contentType: 'application/json' }, ]); }); it.each([ - [{ operationId: 'dataset_delete', pathParams: { datasetId: 'abc' } }, 'Deletion cannot be undone'], - [{ operationId: 'users_me_limits_put', body: {} }, 'spending limits'], - [{ operationId: 'actorRun_metamorph_post', pathParams: { runId: 'run-1' } }, 'cannot be undone'], [ - { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, - `dataset_get is a GET operation with read access; this tool has write access. Call it with ${HELPER_TOOLS.API_READ}.`, + { path: '/v2/datasets/abc', body: { name: 'x' } }, + 'The path matches methods GET, PUT, and DELETE; specify which one to call the endpoint with.', + ], + [ + { path: '/v2/datasets/abc/items', body: [] }, + 'The path matches methods GET and POST; specify which one to call the endpoint with.', ], - [{ operationId: 'dataset_put', pathParams: { datasetId: 'abc' } }, 'dataset_put needs a request body.'], [ - { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, body: null }, - 'Pass the body of dataset_put as a JSON object or array, not as null.', + { path: '/v2/actor-runs/run-1/abort', method: 'PUT' }, + 'The path /v2/actor-runs/run-1/abort has no PUT operation; it matches method POST.', + ], + [ + { path: '/v2/datasets/abc', method: 'DELETE' }, + 'The API tools do not call DELETE /v2/datasets/abc. Deletion cannot be undone', + ], + [{ path: '/v2/users/me/limits', body: {} }, 'The API tools do not call PUT /v2/users/me/limits.'], + [{ path: '/v2/actor-runs/run-1/metamorph' }, 'It turns the run into a run of another Actor'], + [ + { path: '/v2/datasets/abc', method: 'GET' }, + `GET /v2/datasets/abc has read access; this tool has write access. Call it with ${HELPER_TOOLS.API_READ}.`, + ], + [{ path: '/v2/nope', body: {} }, 'No Apify API operation matches the path /v2/nope.'], + [{ path: '/v2/datasets/../users/me/limits', body: {} }, 'The path cannot have a .. segment.'], + [{ path: '/v2/datasets/abc', method: 'PUT' }, 'PUT /v2/datasets/{datasetId} needs a request body.'], + [ + { path: '/v2/datasets/abc', method: 'PUT', body: null }, + 'Pass the body of PUT /v2/datasets/{datasetId} as a JSON object or array, not as null.', ], [ // A JSON-encoded string would otherwise get past the refused-field check. - { operationId: 'actor_put', pathParams: { actorId: 'john~my-actor' }, body: '{"isPublic":true}' }, - 'Pass the body of actor_put as a JSON object or array, not as a string.', + { path: '/v2/actors/john~my-actor', body: '{"isPublic":true}' }, + 'Pass the body of PUT /v2/actors/{actorId} as a JSON object or array, not as a string.', ], [ - { operationId: 'actorRun_abort_post', pathParams: { runId: 'run-1' }, body: { gracefully: true } }, - 'actorRun_abort_post takes no request body.', + { path: '/v2/actor-runs/run-1/abort', body: { gracefully: true } }, + 'POST /v2/actor-runs/{runId}/abort takes no request body.', ], [ - { - operationId: 'actor_put', - pathParams: { actorId: 'john~my-actor' }, - body: { title: 'T', isPublic: false }, - }, + { path: '/v2/actors/john~my-actor', body: { title: 'T', isPublic: false } }, 'The API tools do not set isPublic, whatever the value', ], [ - { operationId: 'actors_post', body: { name: 'x', actorPermissionLevel: 'FULL_PERMISSIONS' } }, + { path: '/v2/actors', body: { name: 'x', actorPermissionLevel: 'FULL_PERMISSIONS' } }, 'The API tools do not set actorPermissionLevel, whatever the value', ], [ - { - operationId: 'dataset_put', - pathParams: { datasetId: 'abc' }, - body: { generalAccess: 'ANYONE_WITH_ID_CAN_READ' }, - }, + { path: '/v2/datasets/abc', method: 'PUT', body: { generalAccess: 'ANYONE_WITH_ID_CAN_READ' } }, 'The API tools do not set generalAccess', ], [ { - operationId: 'actor_version_put', - pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + path: '/v2/actors/john~my-actor/versions/0.1', + method: 'PUT', body: { envVars: [{ name: 'API_KEY', value: 'x', isSecret: true }] }, }, "The API tools do not set envVars: they set an Actor's source, versions, or environment variables, " + 'which dedicated source tools or Apify Console change. To add or change one environment ' + - 'variable, call actor_version_envVars_post or actor_version_envVar_put; each leaves the other ' + + 'variable, call POST /v2/actors/{actorId}/versions/{versionNumber}/env-vars or PUT ' + + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}; each leaves the other ' + 'variables as they are. Without the refused fields the body sets nothing, so do not call again with it.', ], [ - { - operationId: 'actor_version_put', - pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, - body: { sourceFiles: [] }, - }, + { path: '/v2/actors/john~my-actor/versions/0.1', method: 'PUT', body: { sourceFiles: [] } }, 'The API tools do not set sourceFiles', ], - [ - { operationId: 'actor_put', pathParams: { actorId: 'john~my-actor' }, body: { versions: [] } }, - 'The API tools do not set versions', - ], + [{ path: '/v2/actors/john~my-actor', body: { versions: [] } }, 'The API tools do not set versions'], [ { - operationId: 'actor_version_post', - pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + path: '/v2/actors/john~my-actor/versions/0.1', + method: 'POST', body: { buildTag: 'beta', gitHubGistUrl: 'https://gist.github.com/x' }, }, 'The API tools do not set gitHubGistUrl', ], [ { - operationId: 'actor_versions_post', - pathParams: { actorId: 'john~my-actor' }, + path: '/v2/actors/john~my-actor/versions', body: { versionNumber: '0.2', envVars: [{ name: 'API_KEY', value: 'x' }] }, }, - "The API tools do not call actor_versions_post. It sets the new version's source", + "The API tools do not call POST /v2/actors/john~my-actor/versions. It sets the new version's source", ], [ - { - operationId: 'actors_runs_post', - pathParams: { actorId: 'john~my-actor' }, - query: { forcePermissionLevel: 'FULL_PERMISSIONS' }, - }, + { path: '/v2/actors/john~my-actor/runs', query: { forcePermissionLevel: 'FULL_PERMISSIONS' } }, 'The API tools do not set forcePermissionLevel, whatever the value', ], [ - { operationId: 'actors_runs_post', pathParams: { actorId: 'john~my-actor' }, query: { waitForFinish: 60 } }, + { path: '/v2/actors/john~my-actor/runs', query: { waitForFinish: 60 } }, "call the run's or build's GET operation with waitForFinish; calling this operation again starts another one.", ], ])('refuses %j without a request', async (args, reason) => { @@ -592,8 +605,7 @@ describe('apify-api-write', () => { it('lists every refused field in one refusal and says to call again when the rest of the body sets something', async () => { const result = await callTool(apifyApiWrite, { - operationId: 'actor_put', - pathParams: { actorId: 'john~my-actor' }, + path: '/v2/actors/john~my-actor', body: { title: 'T', isPublic: true, versions: [] }, }); @@ -608,7 +620,7 @@ describe('apify-api-write', () => { }); it('does not name the read tool for a read operation when the session lacks it', async () => { - const result = await callTool(apifyApiWrite, { operationId: 'dataset_get', pathParams: { datasetId: 'abc' } }, [ + const result = await callTool(apifyApiWrite, { path: '/v2/datasets/abc', method: 'GET' }, [ HELPER_TOOLS.API_WRITE, ]); @@ -617,24 +629,36 @@ describe('apify-api-write', () => { expect(result.content[0].text).toContain('No tool in this session has read access.'); }); + it('uses the only method of a path whose only method is GET, and refuses it as a read', async () => { + const result = await callTool(apifyApiWrite, { path: '/v2/webhooks' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain('GET /v2/webhooks has read access; this tool has write access.'); + }); + it('sends a version field that does not replace the source, such as buildTag', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { versionNumber: '0.1', buildTag: 'beta' } })); await callTool(apifyApiWrite, { - operationId: 'actor_version_put', - pathParams: { actorId: 'john~my-actor', versionNumber: '0.1' }, + path: '/v2/actors/john~my-actor/versions/0.1', + method: 'PUT', body: { buildTag: 'beta' }, }); - expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ data: '{"buildTag":"beta"}' })); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${BASE_URL}/actors/john~my-actor/versions/0.1`, + method: 'PUT', + data: '{"buildTag":"beta"}', + }), + ); }); it('sends a refused field name when the body is free-form, such as a stored record', async () => { requestMock.mockResolvedValue(mockResponse(201, undefined, '')); const result = await callTool(apifyApiWrite, { - operationId: 'keyValueStore_record_put', - pathParams: { storeId: 'store-1', recordKey: 'CONFIG' }, + path: '/v2/key-value-stores/store-1/records/CONFIG', body: { isPublic: true }, }); @@ -654,15 +678,14 @@ describe('apify-api-write', () => { ); const result = await callTool(apifyApiWrite, { - operationId: 'dataset_put', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc', + method: 'PUT', body: { name: 'leads-2026' }, }); expect(result.isError).toBe(false); expectSchemaConformingStructuredContent(result, apifyApiCallOutputSchema); expect(result.structuredContent).toEqual({ - operationId: 'dataset_put', method: 'PUT', path: '/v2/datasets/abc', statusCode: 200, @@ -680,8 +703,8 @@ describe('apify-api-write', () => { ); const result = await callTool(apifyApiWrite, { - operationId: 'dataset_put', - pathParams: { datasetId: 'abc' }, + path: '/v2/datasets/abc', + method: 'PUT', body: { name: 'leads-2026' }, }); @@ -692,35 +715,34 @@ describe('apify-api-write', () => { const { redactArgs } = apifyApiWrite as HelperTool; it('shares the read tool redactor, so an undeclared token is redacted too', () => { - const args = { operationId: 'dataset_put', pathParams: { datasetId: 'abc' }, query: { token: 'secret' } }; + const args = { path: '/v2/datasets/abc', method: 'PUT', query: { token: 'secret' } }; expect(redactArgs).toBe(redactApiCallArgs); expect(JSON.stringify(redactArgs?.(args))).not.toContain('secret'); }); it('redacts the body in the logged copy without changing the arguments', () => { - const args = { operationId: 'actor_version_envVar_put', body: { value: 'secret' } }; + const path = '/v2/actors/john~my-actor/versions/0.1/env-vars/API_KEY'; + const args = { path, method: 'PUT', body: { value: 'secret' } }; - expect(redactArgs?.(args)).toMatchObject({ operationId: 'actor_version_envVar_put', body: '[REDACTED]' }); + expect(redactArgs?.(args)).toEqual({ path, method: 'PUT', body: '[REDACTED]' }); expect(args.body).toEqual({ value: 'secret' }); }); it('logs only the declared arguments, so a body under another key is left out', () => { - const args = { operationId: 'actor_version_envVar_put', requestBody: { value: 'secret' } }; + const args = { path: '/v2/datasets/abc', requestBody: { value: 'secret' } }; expect(JSON.stringify(redactArgs?.(args))).not.toContain('secret'); }); it('redacts the webhooks query parameter, whose webhooks carry headers', () => { const args = { - operationId: 'actors_runs_post', - pathParams: { actorId: 'john~my-actor' }, + path: '/v2/actors/john~my-actor/runs', query: { memory: 1024, webhooks: 'W3siaGVhZGVyc1RlbXBsYXRlIjoic2VjcmV0In1d' }, }; expect(redactArgs?.(args)).toEqual({ - operationId: 'actors_runs_post', - pathParams: { actorId: 'john~my-actor' }, + path: '/v2/actors/john~my-actor/runs', query: { memory: 1024, webhooks: '[REDACTED]' }, }); expect(args.query.webhooks).not.toBe('[REDACTED]'); From 481da450200e4a3ccc988804fd91e1c937335d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 28 Sep 2026 23:23:45 +0200 Subject: [PATCH 014/103] fix: Redact a query in the logged path and echo templates as written - The logged copy of the arguments keeps the path only up to its first ? or #, so a signature or token written into the path stays out of the log. The path check refuses such a path only after logging. - apify-api-details keeps a {name} placeholder as the agent wrote it, so its refusals show the template, not %7BdatasetId%7D. Its no-match and ? refusals no longer ask for values or for the query field. - The call tools check for a placeholder after decoding, so an encoded %7BdatasetId%7D is refused before it reaches the API. - A lone surrogate in a segment gives the URL encoding refusal instead of an internal error. - The fixture has the request queue batch and request templates, so the precedence and round-trip tests cover the overlap that affects writes. - formatUnavailableMessage and ApiMethod are no longer exported. --- src/tools/api/apify_api_request.ts | 45 ++++++++++++------ src/tools/api/apify_api_spec.ts | 2 +- tests/unit/helpers/apify_api_spec_fixture.ts | 37 +++++++++++++++ tests/unit/tools.apify_api.test.ts | 49 +++++++++++++++++++- tests/unit/tools.apify_api_spec.test.ts | 2 +- 5 files changed, 116 insertions(+), 19 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index d78879492..cf2e6a77d 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -35,11 +35,13 @@ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks' * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps the path and * method, the query with the values of secret parameters redacted, and only a marker for the body, * which can carry environment variable values, webhook headers, or stored records. An undeclared - * `token` is redacted too, since it is logged before the query check refuses it. + * `token` is redacted too, since it is logged before the query check refuses it. So is a query + * written into the path, such as a public URL's `?signature=`, which the path check refuses only + * after the arguments are logged. */ export function redactApiCallArgs({ path, method, query, body }: Record) { return { - path, + path: typeof path === 'string' ? path.replace(/[?#][\s\S]*$/, '?[REDACTED]') : path, method, query: isRecord(query) ? Object.fromEntries( @@ -60,31 +62,41 @@ const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; /** * Checks a request path and returns its segments after `/v2/`, each decoded and encoded again, so a - * value works both encoded and not. `~` stays as it is. A template placeholder is refused unless - * `canBeTemplate` is set: the API would take `{datasetId}` as the value. + * value works both encoded and not. `~` stays as it is. A template placeholder, encoded or not, is + * refused unless `canBeTemplate` is set: the API would take `{datasetId}` as the value. With + * `canBeTemplate`, a placeholder stays as the agent wrote it, since it only matches a template's. */ export function parseApiPath(path: string, canBeTemplate = false): { segments: string[] } | { error: string } { if (!path.startsWith(API_PATH_PREFIX)) { return { error: `The path must start with ${API_PATH_PREFIX}, for example /v2/actor-runs. Do not pass a URL.` }; } if (path.includes('?') || path.includes('#')) { - return { error: 'The path cannot hold ? or #. Pass query parameters in query.' }; + return { + error: canBeTemplate + ? 'The path cannot hold ? or #. Remove the query.' + : 'The path cannot hold ? or #. Pass query parameters in query.', + }; } const segments: string[] = []; for (const rawSegment of path.slice(API_PATH_PREFIX.length).split('/')) { if (!rawSegment) return { error: `The path ${path} has an empty segment. Remove the extra slash.` }; - if (!canBeTemplate && PATH_PARAMETER_SEGMENT_REGEX.test(rawSegment)) { - return { error: `Replace ${rawSegment} in the path with its value.` }; - } let value: string; + let segment: string; try { value = decodeURIComponent(rawSegment); + // A lone surrogate decodes, but does not encode. + segment = encodeURIComponent(value); } catch { return { error: `The path segment ${rawSegment} is not valid URL encoding. Write a % as %25.` }; } + if (PATH_PARAMETER_SEGMENT_REGEX.test(value)) { + if (!canBeTemplate) return { error: `Replace ${value} in the path with its value.` }; + segments.push(value); + continue; + } // Encoded or not, `.` and `..` would move the request to another route. if (value === '.' || value === '..') return { error: `The path cannot have a ${value} segment.` }; - segments.push(encodeURIComponent(value)); + segments.push(segment); } return { segments }; } @@ -104,8 +116,9 @@ function countMatchingLiterals(template: string, segments: string[]): number | u /** * The operations on the one path template the segments match, in index order (GET, POST, PUT, DELETE). - * When several templates match, the one with more literal segments wins, so `/v2/actors/x/runs/last` - * is the last run, not a run with the ID `last`. The published spec has no tie; the first template wins one. + * When several templates match, the one with more literal segments wins, so + * `/v2/request-queues/x/requests/batch` is the batch operation, not a request with the ID `batch`. + * The published spec has no tie; the first template wins one. */ function findPathOperations(index: Map, segments: string[]): ApiOperation[] { let bestLiteralCount = -1; @@ -154,10 +167,12 @@ export function resolvePathOperations(params: { const next = params.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; + // A template is fine for describing operations, so only the call tools ask for the values. + const nameRule = params.canBeTemplate + ? 'A name is written username~name' + : 'Write the path with its values in it; a name is written username~name'; return { - error: - `No Apify API operation matches the path ${path}. Write the path with its values in it; ` + - `a name is written username~name, as in /v2/actors/john~my-actor.${next}`, + error: `No Apify API operation matches the path ${path}. ${nameRule}, as in /v2/actors/john~my-actor.${next}`, }; } @@ -173,7 +188,7 @@ export function resolveMethodOperation( } /** The refusal of an operation the API tools do not call, or `undefined` when they call it. */ -export function formatUnavailableMessage(operation: ApiOperation, path: string): string | undefined { +function formatUnavailableMessage(operation: ApiOperation, path: string): string | undefined { if (operation.access !== API_ACCESS.UNAVAILABLE) return undefined; return `The API tools do not call ${operation.method} ${path}. ${operation.unavailableReason}`; } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 5fe8ea039..6fa3a1ac1 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -14,7 +14,7 @@ export type ApiAccess = (typeof API_ACCESS)[keyof typeof API_ACCESS]; /** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ export const API_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; -export type ApiMethod = (typeof API_METHODS)[number]; +type ApiMethod = (typeof API_METHODS)[number]; /** The API reference has one page per operation, at the operation ID in kebab case. */ const API_DOCS_BASE_URL = 'https://docs.apify.com/api/v2'; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 06e7146aa..f8d111f25 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -249,6 +249,43 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, + // The batch template and a request's template both match /v2/request-queues/q/requests/batch. + '/v2/request-queues/{queueId}/requests/{requestId}': { + get: { + operationId: 'requestQueue_request_get', + summary: 'Get request', + tags: ['Storage/Request queues/Requests'], + parameters: [ + { name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'requestId', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, + put: { + operationId: 'requestQueue_request_put', + summary: 'Update request', + tags: ['Storage/Request queues/Requests'], + parameters: [ + { name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'requestId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, + }, + }, + '/v2/request-queues/{queueId}/requests/batch': { + post: { + operationId: 'requestQueue_requests_batch_post', + summary: 'Add requests', + tags: ['Storage/Request queues'], + parameters: [{ name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, + }, + delete: { + operationId: 'requestQueue_requests_batch_delete', + summary: 'Delete requests', + tags: ['Storage/Request queues'], + parameters: [{ name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, '/outside/v2': { get: { operationId: 'outside_get', summary: 'Outside the API' } }, '/v2/broken': { get: { summary: 'No operation ID' } }, }, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 2f9d0bf5c..4bd299f41 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -89,13 +89,18 @@ describe('parseApiPath()', () => { ['/v2/datasets/%2e/items', 'The path cannot have a . segment.'], ['/v2/datasets/100%', 'The path segment 100% is not valid URL encoding.'], ['/v2/datasets/{datasetId}/items', 'Replace {datasetId} in the path with its value.'], + ['/v2/datasets/%7BdatasetId%7D/items', 'Replace {datasetId} in the path with its value.'], + ['/v2/datasets/\ud800', 'The path segment \ud800 is not valid URL encoding.'], ])('refuses %s', (path, reason) => { const result = parseApiPath(path); expect('error' in result && result.error).toContain(reason); }); - it('takes a template only when asked to', () => { - expect(parseApiPath('/v2/datasets/{datasetId}', true)).toEqual({ segments: ['datasets', '%7BdatasetId%7D'] }); + it('takes a template only when asked to, and keeps its placeholders as they are', () => { + expect(parseApiPath('/v2/datasets/{datasetId}', true)).toEqual({ segments: ['datasets', '{datasetId}'] }); + expect(parseApiPath('/v2/datasets/%7BdatasetId%7D', true)).toEqual({ segments: ['datasets', '{datasetId}'] }); + const withQuery = parseApiPath('/v2/datasets/{datasetId}?limit=1', true); + expect('error' in withQuery && withQuery.error).toBe('The path cannot hold ? or #. Remove the query.'); }); }); @@ -117,6 +122,14 @@ describe('resolvePathOperations()', () => { it('matches a literal segment only to itself and prefers the template with more literal segments', () => { expect(resolveIds('/v2/users/me')).toEqual(['users_me_get']); expect(resolveIds('/v2/users/abc')).toEqual(['user_get']); + expect(resolveIds('/v2/request-queues/q/requests/batch')).toEqual([ + 'requestQueue_requests_batch_post', + 'requestQueue_requests_batch_delete', + ]); + expect(resolveIds('/v2/request-queues/q/requests/r-1')).toEqual([ + 'requestQueue_request_get', + 'requestQueue_request_put', + ]); expect(resolveIds('/v2/actors/abc/runs/last')).toEqual(['actor_runs_last_get']); expect(resolveIds('/v2/actors/abc/runs/run-1')).toEqual([]); }); @@ -143,6 +156,21 @@ describe('resolvePathOperations()', () => { expect(withSearch).toEqual({ error: `${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.` }); expect(withoutSearch).toEqual({ error: message }); }); + + it('echoes a template as it was written and does not ask a template for its values', () => { + const result = resolvePathOperations({ + index: INDEX, + path: '/v2/datasets/{datasetId}/nothing', + loadedToolNames: [], + canBeTemplate: true, + }); + + expect(result).toEqual({ + error: + 'No Apify API operation matches the path /v2/datasets/{datasetId}/nothing. A name is written ' + + 'username~name, as in /v2/actors/john~my-actor.', + }); + }); }); describe('validateQueryParams()', () => { @@ -246,6 +274,14 @@ describe('apify-api-details', () => { expect(result.content[0].text).toBe('The path /v2/actors/abc has no GET operation; it matches method PUT.'); }); + it('echoes a template path with its braces in a refusal', async () => { + const result = await callTool(apifyApiDetails, { path: '/v2/actors/{actorId}', method: 'GET' }); + + expect(result.content[0].text).toBe( + 'The path /v2/actors/{actorId} has no GET operation; it matches method PUT.', + ); + }); + it('names the search tool on an unknown path only when the session has it', async () => { const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); @@ -403,6 +439,15 @@ describe('apify-api-read', () => { expect(args.query.signature).toBe('sig-secret'); }); + it('logs a query written into the path only as redacted', () => { + for (const path of ['/v2/datasets/abc/items?signature=sig-secret', '/v2/datasets/abc?token=token-secret#x']) { + const logged = redactApiCallArgs({ path }); + + expect(logged.path).toBe(`${path.slice(0, path.indexOf('?'))}?[REDACTED]`); + expect(JSON.stringify(logged)).not.toContain('secret'); + } + }); + it('logs only the declared arguments and a body only as redacted', () => { const logged = redactApiCallArgs({ path: '/v2/datasets/abc', diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 6222eef93..c6688e74e 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(26); + expect(index.size).toBe(30); }); it('resolves parameter references and keeps only path and query parameters', () => { From 1c3254893fa9e9df600fcd92084c6803fb40fc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 28 Sep 2026 23:24:49 +0200 Subject: [PATCH 015/103] fix: Name the method and paths in the read and waitForFinish refusals - For a path without GET, the apify-api-read refusal names the method or methods apify-api-write may call, so an agent does not hit the several-methods refusal on a path such as a request queue batch, which has POST and DELETE. - The POST waitForFinish refusal names GET /v2/actor-runs/{runId} and GET /v2/actor-builds/{buildId}, the paths that keep waiting without starting another run or build. - The index size test counts the request queue operations the merge added to the fixture. --- src/tools/api/apify_api_request.ts | 17 +++++++++++------ tests/unit/tools.apify_api.test.ts | 12 +++++++++--- tests/unit/tools.apify_api_spec.test.ts | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index f78e34cc7..f66c606e1 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -212,13 +212,18 @@ export function resolveReadOperation( return unavailableMessage ? { error: unavailableMessage } : { operation }; } -/** Where to call a path without GET: the write tool when it may, else why not. */ +/** + * Where to call a path without GET: the write tool with the method it may call, else why not. The + * method is named since a path with several methods needs it, such as a POST and a DELETE. + */ function formatReadNextStep(operations: readonly ApiOperation[], loadedToolNames: readonly string[]): string { // Without GET, each operation has write access or is unavailable. - const hasWriteOperation = operations.some((candidate) => candidate.access === API_ACCESS.WRITE); - if (!hasWriteOperation) return `The API tools do not call it. ${operations[0].unavailableReason}`; + const writeMethods = operations + .filter((candidate) => candidate.access === API_ACCESS.WRITE) + .map((candidate) => candidate.method); + if (writeMethods.length === 0) return `The API tools do not call it. ${operations[0].unavailableReason}`; return loadedToolNames.includes(HELPER_TOOLS.API_WRITE) - ? `Call it with ${HELPER_TOOLS.API_WRITE}.` + ? `Call it with ${HELPER_TOOLS.API_WRITE} and method ${writeMethods.join(' or ')}.` : 'No tool in this session has write access.'; } @@ -282,8 +287,8 @@ export function validateQueryParams( const next = operation.method === 'GET' ? 'Call the operation again to keep waiting.' - : "To keep waiting after that, call the run's or build's GET operation with waitForFinish; " + - 'calling this operation again starts another one.'; + : 'To keep waiting after that, call GET /v2/actor-runs/{runId} or GET /v2/actor-builds/{buildId} ' + + 'with waitForFinish; calling this operation again starts another one.'; return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. ${next}`; } // It sets the run's permission level, like the refused actorPermissionLevel body field. diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index fe4a7ae2b..34a76c740 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -338,7 +338,12 @@ describe('apify-api-read', () => { [ '/v2/actor-runs/run-1/abort', 'The path /v2/actor-runs/run-1/abort matches method POST, not GET; this tool sends only GET. ' + - `Call it with ${HELPER_TOOLS.API_WRITE}.`, + `Call it with ${HELPER_TOOLS.API_WRITE} and method POST.`, + ], + [ + '/v2/request-queues/q/requests/batch', + 'The path /v2/request-queues/q/requests/batch matches methods POST and DELETE, not GET; this tool ' + + `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE} and method POST.`, ], [ '/v2/actor-runs/run-1/metamorph', @@ -366,7 +371,7 @@ describe('apify-api-read', () => { expect(withWrite.content[0].text).toBe( 'The path /v2/actors/john~my-actor/versions/0.1 matches methods POST and PUT, not GET; this tool ' + - `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE}.`, + `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE} and method POST or PUT.`, ); expect(withoutWrite.content[0].text).not.toContain(HELPER_TOOLS.API_WRITE); expect(withoutWrite.content[0].text).toContain('No tool in this session has write access.'); @@ -638,7 +643,8 @@ describe('apify-api-write', () => { ], [ { path: '/v2/actors/john~my-actor/runs', query: { waitForFinish: 60 } }, - "call the run's or build's GET operation with waitForFinish; calling this operation again starts another one.", + 'call GET /v2/actor-runs/{runId} or GET /v2/actor-builds/{buildId} with waitForFinish; calling this ' + + 'operation again starts another one.', ], ])('refuses %j without a request', async (args, reason) => { const result = await callTool(apifyApiWrite, args); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index e25d9f49f..010ce54bd 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(30); + expect(index.size).toBe(34); }); it('resolves parameter references and keeps only path and query parameters', () => { From a50574f3373621294cf8863094a6f09157641750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:27:52 +0200 Subject: [PATCH 016/103] fix: Key the API refusals by method and path and refuse browser-info The refusals of spending limits, run charging, and metamorphs, and the undeclared pricingInfos and actorPermissionLevel on Actor create, were looked up by operation ID in a spec downloaded at runtime. Operation IDs are docs metadata, so a rename would have made the operation callable. The rules now key on method and path template, with placeholders compared as {}, and the index throws when the spec no longer lists an operation a rule is about, so a changed spec stops the tools instead of lifting a refusal. GET, POST, and PUT /v2/browser-info are now unavailable: the endpoint returns the request headers, which carry the API token. The fixture paths now have the methods they have in the published spec. --- src/tools/api/apify_api_spec.ts | 72 +++++++++--- tests/unit/helpers/apify_api_spec_fixture.ts | 111 +++++++++++++++++++ tests/unit/tools.apify_api.test.ts | 15 ++- tests/unit/tools.apify_api_spec.test.ts | 67 ++++++++++- 4 files changed, 241 insertions(+), 24 deletions(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 6fa3a1ac1..114930a48 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -60,13 +60,39 @@ const METAMORPH_REASON = /** Synchronous run paths. Some are GET, but every one of them starts a paid run. */ const SYNC_RUN_PATH_REGEX = /\/run-sync(-get-dataset-items)?$/; -/** Operations the API tools refuse by ID, with the reason they report. */ -const UNAVAILABLE_OPERATION_REASONS: ReadonlyMap = new Map([ - ['users_me_limits_put', "It changes the account's spending limits. The user can change them in Apify Console."], - ['PostChargeRun', 'It charges the user of a pay-per-event run. Only the Actor itself charges for its events.'], - ['actorRun_metamorph_post', METAMORPH_REASON], - ['actor_runs_last_metamorph_post', METAMORPH_REASON], - ['actorTask_runs_last_metamorph_post', METAMORPH_REASON], +const PATH_PLACEHOLDER_REGEX = /\{[^{}]+\}/g; + +/** + * The key of an operation in the rules below: its method and path template, with every placeholder + * written `{}`, such as `POST /v2/actor-runs/{}/charge`. The rules are keyed by path, the API's + * contract, not by operation ID, which is docs metadata and can be renamed; `{}` keeps a renamed + * path parameter from lifting a rule. + */ +function toRuleKey(methodAndPath: string): string { + return methodAndPath.replace(PATH_PLACEHOLDER_REGEX, '{}'); +} + +function keyRules(rules: readonly (readonly [string, Value])[]): ReadonlyMap { + return new Map(rules.map(([methodAndPath, value]) => [toRuleKey(methodAndPath), value])); +} + +const BROWSER_INFO_REASON = + 'It returns the request headers, and with them the API token the server adds to every request.'; + +/** Operations the API tools refuse by method and path, with the reason they report. */ +const UNAVAILABLE_OPERATION_REASONS = keyRules([ + ['PUT /v2/users/me/limits', "It changes the account's spending limits. The user can change them in Apify Console."], + [ + 'POST /v2/actor-runs/{runId}/charge', + 'It charges the user of a pay-per-event run. Only the Actor itself charges for its events.', + ], + ['POST /v2/actor-runs/{runId}/metamorph', METAMORPH_REASON], + ['POST /v2/actors/{actorId}/runs/last/metamorph', METAMORPH_REASON], + ['POST /v2/actor-tasks/{actorTaskId}/runs/last/metamorph', METAMORPH_REASON], + // A proxy test endpoint; its DELETE is refused like every DELETE. + ['GET /v2/browser-info', BROWSER_INFO_REASON], + ['POST /v2/browser-info', BROWSER_INFO_REASON], + ['PUT /v2/browser-info', BROWSER_INFO_REASON], ]); /** @@ -82,9 +108,13 @@ export const REFUSED_BODY_FIELDS: ReadonlySet = new Set([ ]); /** Refused fields the API accepts on an operation although its body schema does not declare them. */ -const UNDECLARED_REFUSED_BODY_FIELDS: ReadonlyMap = new Map([ - ['actors_post', ['pricingInfos', 'actorPermissionLevel']], -]); +const UNDECLARED_REFUSED_BODY_FIELDS = keyRules([['POST /v2/actors', ['pricingInfos', 'actorPermissionLevel']]]); + +/** Every rule key, each of which must match an operation of the spec. */ +const RULE_KEYS: readonly string[] = [ + ...UNAVAILABLE_OPERATION_REASONS.keys(), + ...UNDECLARED_REFUSED_BODY_FIELDS.keys(), +]; /** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); @@ -178,11 +208,11 @@ function splitWords(text: string): string[] { function resolveAccess( method: ApiMethod, path: string, - operationId: string, + ruleKey: string, ): Pick { if (method === 'DELETE') return { access: API_ACCESS.UNAVAILABLE, unavailableReason: DELETE_REASON }; if (SYNC_RUN_PATH_REGEX.test(path)) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: SYNC_RUN_REASON }; - const reason = UNAVAILABLE_OPERATION_REASONS.get(operationId); + const reason = UNAVAILABLE_OPERATION_REASONS.get(ruleKey); if (reason) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: reason }; return { access: method === 'GET' ? API_ACCESS.READ : API_ACCESS.WRITE }; } @@ -220,19 +250,25 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ /** * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, * and anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. + * It throws when the spec lists no operation, and when an operation a rule is about is missing from + * it: a moved path would otherwise lift the refusal, so the tools stop until the rules are updated. */ export function buildApiOperationIndex(spec: unknown): Map { const index = new Map(); + // Every operation the spec lists, deprecated or malformed ones too, to check the rules against. + const specRuleKeys = new Set(); const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; for (const method of API_METHODS) { + const ruleKey = toRuleKey(`${method} ${path}`); + if (pathItem[method.toLowerCase()] !== undefined) specRuleKeys.add(ruleKey); const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); if (!parsed.success || parsed.data.deprecated) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); const declaredBodyFields = extractTopLevelPropertyNames(body?.schema); - const undeclaredRefusedFields = UNDECLARED_REFUSED_BODY_FIELDS.get(operationId) ?? []; + const undeclaredRefusedFields = UNDECLARED_REFUSED_BODY_FIELDS.get(ruleKey) ?? []; index.set(operationId, { operationId, method, @@ -246,10 +282,18 @@ export function buildApiOperationIndex(spec: unknown): Map refusedBodyFields: [...REFUSED_BODY_FIELDS].filter( (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), ), - ...resolveAccess(method, path, operationId), + ...resolveAccess(method, path, ruleKey), }); } } + if (index.size === 0) throw new Error('the spec lists no /v2/ operations.'); + const missingRuleKeys = RULE_KEYS.filter((ruleKey) => !specRuleKeys.has(ruleKey)); + if (missingRuleKeys.length > 0) { + throw new Error( + `the spec no longer lists ${missingRuleKeys.join(', ')}, which the API tools have rules for. ` + + 'Update the rules in apify_api_spec.ts.', + ); + } return index; } diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index f8d111f25..10cbb0ce8 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -1,6 +1,8 @@ /** * A small OpenAPI document shaped like https://docs.apify.com/api/openapi.json: `$ref` parameters and * bodies, examples, and `x-*` extensions, and one operation of each kind the API tools treat differently. + * Each path has the methods it has in the published spec, and every operation a refusal rule is + * about is here, since the index refuses a spec without them. */ export const API_SPEC_FIXTURE = { openapi: '3.1.2', @@ -57,6 +59,12 @@ export const API_SPEC_FIXTURE = { head: { operationId: 'dataset_items_head', summary: 'Get dataset items headers' }, }, '/v2/actor-runs/{runId}/dataset': { + get: { + operationId: 'actorRun_dataset_get', + summary: 'Get default dataset', + tags: ['Default dataset'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, put: { operationId: 'actorRun_dataset_put', summary: 'Update default dataset', @@ -66,6 +74,12 @@ export const API_SPEC_FIXTURE = { content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateDatasetRequest' } } }, }, }, + delete: { + operationId: 'actorRun_dataset_delete', + summary: 'Delete default dataset', + tags: ['Default dataset'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, }, '/v2/actor-runs/{runId}': { get: { @@ -77,6 +91,19 @@ export const API_SPEC_FIXTURE = { { name: 'waitForFinish', in: 'query', schema: { type: 'number' } }, ], }, + put: { + operationId: 'actorRun_put', + summary: 'Update run', + tags: ['Actor runs'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, + }, + delete: { + operationId: 'actorRun_delete', + summary: 'Delete run', + tags: ['Actor runs'], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], + }, }, '/v2/actor-runs/{runId}/log': { get: { @@ -86,6 +113,8 @@ export const API_SPEC_FIXTURE = { parameters: [ { name: 'runId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'stream', in: 'query', schema: { type: 'boolean' } }, + { name: 'download', in: 'query', schema: { type: 'boolean' } }, + { name: 'raw', in: 'query', schema: { type: 'boolean' } }, ], }, }, @@ -114,6 +143,15 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors': { + get: { + operationId: 'actors_get', + summary: 'Get list of Actors', + tags: ['Actors'], + parameters: [ + { name: 'offset', in: 'query', schema: { type: 'number' } }, + { name: 'limit', in: 'query', schema: { type: 'number' } }, + ], + }, post: { operationId: 'actors_post', summary: 'Create Actor', @@ -125,6 +163,12 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors/{actorId}': { + get: { + operationId: 'actor_get', + summary: 'Get Actor', + tags: ['Actors'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, put: { operationId: 'actor_put', summary: 'Update Actor', @@ -134,6 +178,12 @@ export const API_SPEC_FIXTURE = { content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateActorRequest' } } }, }, }, + delete: { + operationId: 'actor_delete', + summary: 'Delete Actor', + tags: ['Actors'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, }, // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset // in their summary: search must still rank the storage's own operation first. @@ -189,6 +239,13 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors/{actorId}/run-sync': { + post: { + operationId: 'actor_runSync_post', + summary: 'Run Actor synchronously with input and return output', + tags: ['Actors/Actor runs'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + }, get: { operationId: 'actor_runSync_get', summary: 'Run Actor synchronously without input', @@ -205,6 +262,15 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/key-value-stores/{storeId}/records/{recordKey}': { + get: { + operationId: 'keyValueStore_record_get', + summary: 'Get record', + tags: ['Storage/Key-value stores'], + parameters: [ + { name: 'storeId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'recordKey', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, put: { operationId: 'keyValueStore_record_put', summary: 'Store record', @@ -218,6 +284,28 @@ export const API_SPEC_FIXTURE = { content: { '*/*': { schema: { type: 'object', additionalProperties: true } } }, }, }, + post: { + operationId: 'keyValueStore_record_post', + summary: 'Store record (POST)', + tags: ['Storage/Key-value stores'], + parameters: [ + { name: 'storeId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'recordKey', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { '*/*': { schema: { type: 'object', additionalProperties: true } } }, + }, + }, + delete: { + operationId: 'keyValueStore_record_delete', + summary: 'Delete record', + tags: ['Storage/Key-value stores'], + parameters: [ + { name: 'storeId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'recordKey', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, }, '/v2/users/{userId}': { get: { @@ -231,6 +319,7 @@ export const API_SPEC_FIXTURE = { get: { operationId: 'users_me_get', summary: 'Get private user data', tags: ['Users/Private data'] }, }, '/v2/users/me/limits': { + get: { operationId: 'users_me_limits_get', summary: 'Get limits', tags: ['Users/Usage'] }, put: { operationId: 'users_me_limits_put', summary: 'Update limits', @@ -240,6 +329,12 @@ export const API_SPEC_FIXTURE = { }, '/v2/webhooks': { get: { operationId: 'webhooks_get', summary: 'Get list of webhooks', tags: ['Webhooks/Webhooks'] }, + post: { + operationId: 'webhooks_post', + summary: 'Create webhook', + tags: ['Webhooks/Webhooks'], + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, + }, }, '/v2/actors/{actorId}/webhooks': { get: { @@ -270,6 +365,15 @@ export const API_SPEC_FIXTURE = { ], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, }, + delete: { + operationId: 'requestQueue_request_delete', + summary: 'Delete request', + tags: ['Storage/Request queues/Requests'], + parameters: [ + { name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'requestId', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, }, '/v2/request-queues/{queueId}/requests/batch': { post: { @@ -286,6 +390,13 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }], }, }, + // Echoes the request headers, and with them the API token. + '/v2/browser-info': { + get: { operationId: 'tools_browser_info_get', summary: 'Get browser info', tags: ['Tools'] }, + post: { operationId: 'tools_browser_info_post', summary: 'Post browser info', tags: ['Tools'] }, + put: { operationId: 'tools_browser_info_put', summary: 'Put browser info', tags: ['Tools'] }, + delete: { operationId: 'tools_browser_info_delete', summary: 'Delete browser info', tags: ['Tools'] }, + }, '/outside/v2': { get: { operationId: 'outside_get', summary: 'Outside the API' } }, '/v2/broken': { get: { summary: 'No operation ID' } }, }, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 4bd299f41..4cfaf22fd 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -129,6 +129,7 @@ describe('resolvePathOperations()', () => { expect(resolveIds('/v2/request-queues/q/requests/r-1')).toEqual([ 'requestQueue_request_get', 'requestQueue_request_put', + 'requestQueue_request_delete', ]); expect(resolveIds('/v2/actors/abc/runs/last')).toEqual(['actor_runs_last_get']); expect(resolveIds('/v2/actors/abc/runs/run-1')).toEqual([]); @@ -268,17 +269,19 @@ describe('apify-api-details', () => { }); it('refuses a method the path does not have and lists the ones it has', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/actors/abc', method: 'GET' }); + const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/abc/abort', method: 'GET' }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe('The path /v2/actors/abc has no GET operation; it matches method PUT.'); + expect(result.content[0].text).toBe( + 'The path /v2/actor-runs/abc/abort has no GET operation; it matches method POST.', + ); }); it('echoes a template path with its braces in a refusal', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/actors/{actorId}', method: 'GET' }); + const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/{runId}/abort', method: 'GET' }); expect(result.content[0].text).toBe( - 'The path /v2/actors/{actorId} has no GET operation; it matches method PUT.', + 'The path /v2/actor-runs/{runId}/abort has no GET operation; it matches method POST.', ); }); @@ -342,6 +345,10 @@ describe('apify-api-read', () => { '/v2/actors/abc/run-sync', 'The API tools do not call GET /v2/actors/abc/run-sync. It waits up to 300 seconds', ], + [ + '/v2/browser-info', + 'The API tools do not call GET /v2/browser-info. It returns the request headers, and with them the API token', + ], ['/v2/nope', 'No Apify API operation matches the path /v2/nope.'], ['/v2/datasets/{datasetId}', 'Replace {datasetId} in the path with its value.'], ['/v2/datasets/abc?limit=1', 'Pass query parameters in query.'], diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index c6688e74e..d82a23aa9 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(30); + expect(index.size).toBe(48); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -60,6 +60,10 @@ describe('buildApiOperationIndex()', () => { ['actor_runs_last_metamorph_post', 'cannot be undone'], ['actorTask_runs_last_metamorph_post', 'cannot be undone'], ['actor_runSyncGetDatasetItems_post', 'waits up to 300 seconds'], + ['actor_runSync_post', 'waits up to 300 seconds'], + ['tools_browser_info_get', 'the API token'], + ['tools_browser_info_post', 'the API token'], + ['tools_browser_info_put', 'the API token'], ])('makes %s unavailable with the reason', (operationId, reason) => { const operation = index.get(operationId); expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); @@ -87,9 +91,59 @@ describe('buildApiOperationIndex()', () => { ); }); - it('returns an empty index for a document without paths', () => { - expect(buildApiOperationIndex({}).size).toBe(0); - expect(buildApiOperationIndex('not a spec').size).toBe(0); + it('throws for a document without /v2/ operations', () => { + expect(() => buildApiOperationIndex({})).toThrow('the spec lists no /v2/ operations.'); + expect(() => buildApiOperationIndex('not a spec')).toThrow('the spec lists no /v2/ operations.'); + }); + + /** The fixture with the operation at the path and method replaced. */ + function withOperation(path: string, method: string, operation: Record | undefined) { + const paths: Record> = structuredClone(API_SPEC_FIXTURE.paths); + paths[path] = { ...paths[path], [method]: operation }; + return { ...API_SPEC_FIXTURE, paths }; + } + + it('keys the refusals by method and path, not by the operation ID or the parameter names', () => { + const renamed = buildApiOperationIndex( + withOperation('/v2/actor-runs/{runId}/charge', 'post', { + operationId: 'actorRun_charge_post', + summary: 'Charge events in run', + }), + ); + expect(renamed.get('actorRun_charge_post')?.access).toBe(API_ACCESS.UNAVAILABLE); + + const paths: Record = structuredClone(API_SPEC_FIXTURE.paths); + paths['/v2/actor-runs/{id}/metamorph'] = paths['/v2/actor-runs/{runId}/metamorph']; + delete paths['/v2/actor-runs/{runId}/metamorph']; + const reparameterized = buildApiOperationIndex({ ...API_SPEC_FIXTURE, paths }); + expect(reparameterized.get('actorRun_metamorph_post')?.access).toBe(API_ACCESS.UNAVAILABLE); + + const createActor = withOperation('/v2/actors', 'post', { + ...API_SPEC_FIXTURE.paths['/v2/actors'].post, + operationId: 'actor_create', + }); + expect(buildApiOperationIndex(createActor).get('actor_create')?.refusedBodyFields).toEqual([ + 'isPublic', + 'pricingInfos', + 'actorPermissionLevel', + ]); + }); + + it('throws when an operation a rule is about is missing, rather than lift the rule', () => { + const moved = withOperation('/v2/users/me/limits', 'put', undefined); + + expect(() => buildApiOperationIndex(moved)).toThrow( + 'the spec no longer lists PUT /v2/users/me/limits, which the API tools have rules for.', + ); + }); + + it('checks the rules against a deprecated operation too', () => { + const deprecated = withOperation('/v2/users/me/limits', 'put', { + ...API_SPEC_FIXTURE.paths['/v2/users/me/limits'].put, + deprecated: true, + }); + + expect(buildApiOperationIndex(deprecated).has('users_me_limits_put')).toBe(false); }); }); @@ -107,11 +161,12 @@ describe('searchApiOperations()', () => { it('matches word prefixes and ignores stop words', () => { // Without the stop words, "with" would also match "Run Actor synchronously without input". - expect(searchIds('webhook with')).toEqual(['webhooks_get', 'actor_webhooks_get']); + expect(searchIds('webhook with')).toEqual(['webhooks_get', 'webhooks_post', 'actor_webhooks_get']); }); it('matches a plural to its singular and drops one-letter terms', () => { - expect(searchIds('abort runs')[0]).toBe('actorRun_abort_post'); + // No word starts with "aborts"; only the plural rule matches it to "abort". + expect(searchIds('aborts')).toEqual(['actorRun_abort_post']); expect(searchIds("dataset's")).toEqual(searchIds('dataset')); }); From a45b9994628d89932c9a928a1a3d2a2f7dd2fa37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:28:04 +0200 Subject: [PATCH 017/103] fix: Time out the API spec download and never cache an empty index Every API tool call in the process waits on one shared download, which had no timeout. It now gives up after 30 seconds, so the next call tries again. A body that is not JSON, or a spec with no /v2/ operations, now fails with the usual load error instead of a raw SyntaxError or an empty index cached for an hour. --- src/tools/api/apify_api_spec.ts | 46 +++++++++++++++++-------- tests/unit/tools.apify_api_spec.test.ts | 19 +++++++++- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 114930a48..ea9888df7 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -297,28 +297,46 @@ export function buildApiOperationIndex(spec: unknown): Map return index; } +/** How long the spec download may take; MCP clients stop waiting for a tool call after 60 seconds. */ +const SPEC_DOWNLOAD_TIMEOUT_MS = 30_000; + let pendingIndex: Promise> | undefined; +async function downloadApiOperationIndex(): Promise> { + const failure = `Failed to load the Apify API operations from ${APIFY_API_OPENAPI_URL}`; + // Every session waits on this one download, so a stalled one must not hold them all. + const response = await fetch(APIFY_API_OPENAPI_URL, { signal: AbortSignal.timeout(SPEC_DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`${failure}: HTTP ${response.status}.`); + let spec: unknown; + try { + spec = await response.json(); + } catch { + throw new Error(`${failure}: the response is not JSON.`); + } + // An index without operations, or without an operation a rule refuses, is not cached. + try { + return buildApiOperationIndex(spec); + } catch (error) { + throw new Error(`${failure}: ${error instanceof Error ? error.message : String(error)}`); + } +} + /** * The operation index, from the published spec, cached for an hour. Concurrent calls share one - * download. A failed download throws: the tools never call an operation the spec does not list. + * download. A failed download throws and is not cached: the tools never call an operation the spec + * does not list, and the next call tries again. */ export async function fetchApiOperationIndex(): Promise> { const cached = apifyApiOperationsCache.get(APIFY_API_OPENAPI_URL); if (cached) return cached; - pendingIndex ??= (async () => { - const response = await fetch(APIFY_API_OPENAPI_URL); - if (!response.ok) { - throw new Error( - `Failed to load the Apify API operations from ${APIFY_API_OPENAPI_URL}: HTTP ${response.status}.`, - ); - } - const index = buildApiOperationIndex(await response.json()); - apifyApiOperationsCache.set(APIFY_API_OPENAPI_URL, index); - return index; - })().finally(() => { - pendingIndex = undefined; - }); + pendingIndex ??= downloadApiOperationIndex() + .then((index) => { + apifyApiOperationsCache.set(APIFY_API_OPENAPI_URL, index); + return index; + }) + .finally(() => { + pendingIndex = undefined; + }); return pendingIndex; } diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index d82a23aa9..807000253 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -223,7 +223,7 @@ describe('fetchApiOperationIndex()', () => { const third = await fetchApiOperationIndex(); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith(APIFY_API_OPENAPI_URL); + expect(fetchMock).toHaveBeenCalledWith(APIFY_API_OPENAPI_URL, { signal: expect.any(AbortSignal) }); expect(first.has('dataset_get')).toBe(true); expect(second).toBe(first); expect(third).toBe(first); @@ -241,4 +241,21 @@ describe('fetchApiOperationIndex()', () => { ); expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it.each([ + ['a body that is not JSON', '', 'the response is not JSON.'], + ['a spec without /v2/ operations', JSON.stringify({ openapi: '3.1.2' }), 'the spec lists no /v2/ operations.'], + ])('throws on %s, does not cache it, and tries again on the next call', async (_name, body, reason) => { + fetchMock + .mockResolvedValueOnce(new Response(body)) + .mockResolvedValueOnce(new Response(JSON.stringify(API_SPEC_FIXTURE))); + const fetchApiOperationIndex = await importFetchApiOperationIndex(); + + await expect(fetchApiOperationIndex()).rejects.toThrow( + `Failed to load the Apify API operations from ${APIFY_API_OPENAPI_URL}: ${reason}`, + ); + await expect(fetchApiOperationIndex()).resolves.toSatisfy((index: Map) => + index.has('dataset_get'), + ); + }); }); From 36e6f2f240b056b0cabaad2415a1d96e43c59281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:28:37 +0200 Subject: [PATCH 018/103] fix: Redact the apify-api-details path and wrong-type arguments in logs apify-api-details takes the same path as the call tools but logged it whole, so a URL pasted with ?token= or ?signature= reached the INFO and DEBUG logs before the path check refused it. It now logs through redactApiCallArgs too. The redactor runs before AJV and passed a query that is not an object, such as "signature=...", or a path that is not a string, through as the agent sent it. Both are now logged as [REDACTED]. --- src/tools/api/apify_api_details.ts | 4 +++- src/tools/api/apify_api_request.ts | 30 ++++++++++++++++++----------- tests/unit/tools.apify_api.test.ts | 31 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 4dc595e7e..30906a661 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,7 +7,7 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; -import { resolveMethodOperation, resolvePathOperations } from './apify_api_request.js'; +import { redactApiCallArgs, resolveMethodOperation, resolvePathOperations } from './apify_api_request.js'; import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ @@ -60,6 +60,8 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ idempotentHint: true, openWorldHint: false, }, + // Only path and method are declared; a query written into a pasted path, such as ?token=, is cut. + redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index cf2e6a77d..609dc8b3a 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -31,27 +31,35 @@ export const apiCallArgsShape = { /** Query parameters whose values grant access or carry secrets: a storage signature, and webhooks with headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; +/** Stands in for a logged value that may carry a secret. */ +const REDACTED = '[REDACTED]'; + /** * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps the path and * method, the query with the values of secret parameters redacted, and only a marker for the body, * which can carry environment variable values, webhook headers, or stored records. An undeclared * `token` is redacted too, since it is logged before the query check refuses it. So is a query * written into the path, such as a public URL's `?signature=`, which the path check refuses only - * after the arguments are logged. + * after the arguments are logged. A path or query of the wrong type, which AJV refuses after the + * arguments are logged, is logged only as a marker. */ export function redactApiCallArgs({ path, method, query, body }: Record) { + let loggedQuery: unknown; + if (isRecord(query)) { + loggedQuery = Object.fromEntries( + Object.entries(query).map(([name, value]) => [name, SECRET_QUERY_PARAMS.includes(name) ? REDACTED : value]), + ); + } else if (query !== undefined) { + loggedQuery = REDACTED; + } + let loggedPath: unknown; + if (typeof path === 'string') loggedPath = path.replace(/[?#][\s\S]*$/, `?${REDACTED}`); + else if (path !== undefined) loggedPath = REDACTED; return { - path: typeof path === 'string' ? path.replace(/[?#][\s\S]*$/, '?[REDACTED]') : path, + path: loggedPath, method, - query: isRecord(query) - ? Object.fromEntries( - Object.entries(query).map(([name, value]) => [ - name, - SECRET_QUERY_PARAMS.includes(name) ? '[REDACTED]' : value, - ]), - ) - : query, - ...(body !== undefined && { body: '[REDACTED]' }), + query: loggedQuery, + ...(body !== undefined && { body: REDACTED }), }; } diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 4cfaf22fd..403cd9a9a 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -285,6 +285,24 @@ describe('apify-api-details', () => { ); }); + it('logs a query written into the path only as redacted', () => { + expect((apifyApiDetails as HelperTool).redactArgs).toBe(redactApiCallArgs); + expect(redactApiCallArgs({ path: '/v2/datasets/abc?token=token-secret', method: 'GET' })).toEqual({ + path: '/v2/datasets/abc?[REDACTED]', + method: 'GET', + query: undefined, + }); + }); + + it('names the search tool on an unknown path only when the session has it', async () => { + const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); + const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); + + expectSoftFailInvalidInput(withSearch); + expect(withSearch.content[0].text).toContain(`Find the path with ${HELPER_TOOLS.API_SEARCH}.`); + expect(withoutSearch.content[0].text).not.toContain(HELPER_TOOLS.API_SEARCH); + }); + it('names the search tool on an unknown path only when the session has it', async () => { const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); @@ -466,4 +484,17 @@ describe('apify-api-read', () => { expect(logged).toEqual({ path: '/v2/datasets/abc', method: 'PUT', query: undefined, body: '[REDACTED]' }); expect(JSON.stringify(logged)).not.toContain('secret'); }); + + it('logs a query or a path of the wrong type only as redacted', () => { + for (const args of [ + { path: '/v2/datasets/abc', query: 'signature=sig-secret' }, + { path: '/v2/datasets/abc', query: ['token=token-secret'] }, + { path: ['/v2/datasets/abc?token=token-secret'] }, + ]) { + const logged = redactApiCallArgs(args); + + expect(JSON.stringify(logged)).not.toContain('secret'); + expect(JSON.stringify(logged)).toContain('[REDACTED]'); + } + }); }); From b4d0b7c9d430ecd7f72ea5005ee0513d7a61ed4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:28:53 +0200 Subject: [PATCH 019/103] test: Remove a duplicated apify-api-details test --- tests/unit/tools.apify_api.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 403cd9a9a..44aba139a 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -302,15 +302,6 @@ describe('apify-api-details', () => { expect(withSearch.content[0].text).toContain(`Find the path with ${HELPER_TOOLS.API_SEARCH}.`); expect(withoutSearch.content[0].text).not.toContain(HELPER_TOOLS.API_SEARCH); }); - - it('names the search tool on an unknown path only when the session has it', async () => { - const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); - const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); - - expectSoftFailInvalidInput(withSearch); - expect(withSearch.content[0].text).toContain(`Find the path with ${HELPER_TOOLS.API_SEARCH}.`); - expect(withoutSearch.content[0].text).not.toContain(HELPER_TOOLS.API_SEARCH); - }); }); describe('apify-api-read', () => { From 0f22829fc4a6b078a9fecc665edbfe2f436fae9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:29:19 +0200 Subject: [PATCH 020/103] fix: Name the parameters that narrow a response over the inline limit Every oversize GET got the same advice to narrow it with limit, offset, or fields, also on operations that declare none, such as run logs. The advice now names the narrowing parameters the operation declares. A log is sent to get-actor-run-log or get-actor-build-log when the session has it; otherwise the message says the response cannot be narrowed. The oversize test now builds the abort as real axios does, with the response status on the request. --- src/tools/api/apify_api_read.ts | 1 + src/tools/api/apify_api_request.ts | 50 ++++++++++++++++++++++++------ tests/unit/tools.apify_api.test.ts | 35 +++++++++++++++++---- 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 84e1f2a34..a1745eef7 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -84,6 +84,7 @@ export const apifyApiRead: ToolEntry = Object.freeze({ path: matched.path, query: parsed.query, signal: toolArgs.signal, + loadedToolNames: toolArgs.loadedToolNames, }); }, } as const); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 609dc8b3a..d3b699017 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -144,11 +144,18 @@ function findPathOperations(index: Map, segments: string[] return operations; } -/** Method names in a sentence, with an Oxford comma: `GET, PUT, and DELETE`. */ +/** Words in a sentence, with an Oxford comma: `GET, PUT, and DELETE`. */ +function formatList(words: readonly string[], conjunction: 'and' | 'or'): string { + if (words.length <= 2) return words.join(` ${conjunction} `); + return `${words.slice(0, -1).join(', ')}, ${conjunction} ${words.at(-1)}`; +} + +/** Method names in a sentence: `GET, PUT, and DELETE`. */ function formatMethodList(operations: readonly ApiOperation[]): string { - const methods = operations.map((operation) => operation.method); - if (methods.length <= 2) return methods.join(' and '); - return `${methods.slice(0, -1).join(', ')}, and ${methods.at(-1)}`; + return formatList( + operations.map((operation) => operation.method), + 'and', + ); } /** `method POST` or `methods GET and PUT`. */ @@ -254,11 +261,31 @@ export function validateQueryParams( return undefined; } -function formatOversizeMessage(operation: ApiOperation, path: string): string { - return ( - `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ` + - 'Narrow the request, for example with the limit, offset, or fields query parameters if the operation declares them.' - ); +/** Query parameters that make a response smaller. */ +const NARROWING_QUERY_PARAMS: readonly string[] = ['limit', 'offset', 'fields', 'omit']; + +/** The dedicated tool that returns the end of a log too large for the API tools, when the session has it. */ +function findLogToolName(path: string, loadedToolNames: readonly string[]): string | undefined { + if (!/\/log$|^\/v2\/logs\//.test(path)) return undefined; + const toolName = path.startsWith('/v2/actor-builds/') ? HELPER_TOOLS.ACTOR_BUILD_LOG : HELPER_TOOLS.ACTOR_RUNS_LOG; + return loadedToolNames.includes(toolName) ? toolName : undefined; +} + +/** The refusal of a response over the inline limit, with the parameters the operation has to narrow it. */ +function formatOversizeMessage(operation: ApiOperation, path: string, loadedToolNames: readonly string[]): string { + const narrowingNames = operation.parameters + .filter((parameter) => parameter.in === 'query' && NARROWING_QUERY_PARAMS.includes(parameter.name)) + .map((parameter) => parameter.name); + const logToolName = findLogToolName(operation.path, loadedToolNames); + let next: string; + if (narrowingNames.length > 0) { + next = `Narrow the request with the ${formatList(narrowingNames, 'or')} query parameter.`; + } else if (logToolName) { + next = `Get the end of the log with ${logToolName} instead.`; + } else { + next = 'The operation has no query parameter that narrows it, so the API tools cannot return it.'; + } + return `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ${next}`; } /** @@ -290,6 +317,8 @@ export async function callApiOperation(params: { query?: Record; /** Aborts the request when the client cancels the tool call. */ signal?: AbortSignal; + /** The session's tools, to name a dedicated log tool when a log is too large. */ + loadedToolNames: readonly string[]; }): Promise { const { client, operation, path } = params; let response: AxiosResponse; @@ -305,7 +334,8 @@ export async function callApiOperation(params: { } catch (error) { // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. if (params.signal?.aborted) return respondAborted(); - if (isMaxContentLengthAbort(error)) return respondUserError(formatOversizeMessage(operation, path)); + if (isMaxContentLengthAbort(error)) + return respondUserError(formatOversizeMessage(operation, path, params.loadedToolNames)); throw toPlainRequestError(error); } if (response.status >= 300) throw new ApifyApiError(response, 1); diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 44aba139a..67d5f45c6 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -394,16 +394,39 @@ describe('apify-api-read', () => { }); }); - it('does not return a body over the inline limit', async () => { - requestMock.mockRejectedValue( - new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), - ); + /** The abort axios throws for a body over `maxContentLength`; Node's request keeps the response status. */ + function buildOversizeError(statusCode: number) { + return new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE', undefined, { + res: { statusCode }, + }); + } + + it('does not return a body over the inline limit and names the parameters that narrow it', async () => { + requestMock.mockRejectedValue(buildOversizeError(200)); const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc/items', query: { format: 'json' } }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toContain(`larger than ${MAX_INLINE_BYTES} bytes`); - expect(result.content[0].text).toContain('limit, offset, or fields'); + expect(result.content[0].text).toBe( + `The response of GET /v2/datasets/abc/items is larger than ${MAX_INLINE_BYTES} bytes, so it is not ` + + 'returned. Narrow the request with the limit query parameter.', + ); + }); + + it('sends a log over the inline limit to the log tool when the session has it', async () => { + requestMock.mockRejectedValue(buildOversizeError(200)); + + const withLogTool = await callTool(apifyApiRead, { path: '/v2/actor-runs/abc/log' }, [ + HELPER_TOOLS.ACTOR_RUNS_LOG, + ]); + const withoutLogTool = await callTool(apifyApiRead, { path: '/v2/actor-runs/abc/log' }, []); + + expect(withLogTool.content[0].text).toContain( + `Get the end of the log with ${HELPER_TOOLS.ACTOR_RUNS_LOG} instead.`, + ); + expect(withoutLogTool.content[0].text).toContain( + 'The operation has no query parameter that narrows it, so the API tools cannot return it.', + ); }); it('rethrows any other request failure without the request config, which holds the token', async () => { From 9905ce77862560569dbe39118d1f160ea521c3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:29:36 +0200 Subject: [PATCH 021/103] fix: Leave the refused body fields out of the apify-api-details schema Details returned the full body schema, so for PUT /v2/actors/{actorId} most of the tokens described pricingInfos and other fields the write tool refuses, and showed the agent their shape. The returned schema now leaves them out, at the top level and in allOf, anyOf, and oneOf parts; refusedBodyFields still lists them. The index keeps the full schema. --- src/tools/api/apify_api_details.ts | 31 ++++++++++++++++++++++++-- src/tools/structured_output_schemas.ts | 2 +- tests/unit/tools.apify_api.test.ts | 15 +++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 30906a661..caaf7b7e2 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -8,7 +8,7 @@ import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; import { redactApiCallArgs, resolveMethodOperation, resolvePathOperations } from './apify_api_request.js'; -import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; +import { API_METHODS, fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ path: z @@ -24,6 +24,28 @@ const apifyApiDetailsArgs = z.object({ .describe('HTTP method of the operation. Omit it to get every operation on the path.'), }); +/** + * A copy of a body schema without the refused fields, at its top level and in its `allOf`/`anyOf`/`oneOf` + * parts, the same places the refusal looks. The agent may not send them, and their schemas cost context. + */ +function omitRefusedFields(schema: unknown, refusedBodyFields: readonly string[]): unknown { + if (!isRecord(schema)) return schema; + const result: Record = { ...schema }; + if (isRecord(schema.properties)) { + result.properties = Object.fromEntries( + Object.entries(schema.properties).filter(([name]) => !refusedBodyFields.includes(name)), + ); + } + if (Array.isArray(schema.required)) { + result.required = schema.required.filter((name) => !refusedBodyFields.includes(name)); + } + for (const key of ['allOf', 'anyOf', 'oneOf']) { + const parts = schema[key]; + if (Array.isArray(parts)) result[key] = parts.map((part) => omitRefusedFields(part, refusedBodyFields)); + } + return result; +} + function buildDescription({ hasTool }: ToolDescriptionContext): string { const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; return dedent` @@ -87,7 +109,12 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ access: operation.access, ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), parameters: operation.parameters, - ...(operation.requestBody && { requestBody: operation.requestBody }), + ...(operation.requestBody && { + requestBody: { + ...operation.requestBody, + schema: omitRefusedFields(operation.requestBody.schema, operation.refusedBodyFields), + }, + }), refusedBodyFields: operation.refusedBodyFields, })), }; diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 9d917c27b..4d1e6c11d 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -942,7 +942,7 @@ const apiOperationDetailsSchema = { type: 'object' as const, properties: { isRequired: { type: 'boolean', description: 'Whether the body is required' }, - schema: { description: 'JSON schema of the body, sent as JSON' }, + schema: { description: 'JSON schema of the body, sent as JSON, without the refused body fields' }, }, required: ['isRequired', 'schema'], description: 'Only when the operation takes a body', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 67d5f45c6..9352526d6 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -268,6 +268,21 @@ describe('apify-api-details', () => { }); }); + it('leaves the refused fields out of the body schema it returns', async () => { + const result = await callTool(apifyApiDetails, { path: '/v2/actors/abc', method: 'PUT' }); + + const [operation] = (result.structuredContent as { operations: { requestBody: { schema: unknown } }[] }) + .operations; + expect(operation.requestBody.schema).toEqual({ + allOf: [{ type: 'object', properties: {} }], + properties: { title: { type: 'string' } }, + }); + // The index keeps the full schema. + expect(INDEX.get('actor_put')?.requestBody?.schema).toMatchObject({ + properties: { isPublic: { type: 'boolean' }, pricingInfos: { type: 'array' } }, + }); + }); + it('refuses a method the path does not have and lists the ones it has', async () => { const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/abc/abort', method: 'GET' }); From a3bb7b4c301d16c7f93c15a4520c0bc7c9c022bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:29:46 +0200 Subject: [PATCH 022/103] fix: Send the agent to apify-api-details with the method, not only the path An agent that followed the search and read descriptions passed only the path and got every operation on it, with the body schemas of operations it would not call. The search result already gives the method. --- src/tools/api/apify_api_read.ts | 2 +- src/tools/api/apify_api_search.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index a1745eef7..1d6160867 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -22,7 +22,7 @@ const apifyApiReadArgs = z.object(apiCallArgsShape); function buildDescription({ hasTool }: ToolDescriptionContext): string { const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? `\nFind the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) - ? `\nGet the operation's query parameters first with ${HELPER_TOOLS.API_DETAILS}.` + ? `\nGet the operation's query parameters first with ${HELPER_TOOLS.API_DETAILS} and method GET.` : ''; return dedent` Send a GET request to the Apify API at a path with its values in it, such as /v2/actor-runs/abc. diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 19cb806e7..3b5271740 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -26,7 +26,8 @@ const apifyApiSearchArgs = z.object({ function buildDescription({ hasTool }: ToolDescriptionContext): string { const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); const nextSteps = [ - hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS} by its path`, + hasTool(HELPER_TOOLS.API_DETAILS) && + `get its parameters with ${HELPER_TOOLS.API_DETAILS} by its method and path`, callTools.length > 0 && `call it with ${callTools.join(' or ')}`, ].filter(Boolean); const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; From 4f3aca23b7ce3c3485fd1fd7f683bfb424f5324c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:29:46 +0200 Subject: [PATCH 023/103] docs: Describe the path-keyed API rules and the details redaction in AGENTS.md --- src/tools/AGENTS.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 11d55f1df..4fe401d3d 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -35,11 +35,14 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the operations on a path, and send a GET to a path. `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with - a reason. Operation IDs are internal keys for these rules; the tools take and return method and - path only. `apify_api_request.ts` normalizes the real path the agent gives, matches it to a path - template (more literal segments win), checks the query, and sends one request with no retries. - The call tools log their arguments through `redactApiCallArgs` (`redactArgs`): only the declared - keys, with the body and the `token`, `signature`, and `webhooks` query values redacted. + a reason. The rules are keyed by method and path template, not by operation ID, and a spec that no + longer lists an operation a rule is about is refused, like a failed download. The tools take and + return method and path only. `apify_api_request.ts` normalizes the real path the agent gives, + matches it to a path template (more literal segments win), checks the query, and sends one request + with no retries. The details and call tools log their arguments through `redactApiCallArgs` + (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, + `signature`, and `webhooks` query values redacted, and a query written into the path cut to + `?[REDACTED]`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). From 9408b348361223e7b6e275d32bfc591b48b1b36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:32:23 +0200 Subject: [PATCH 024/103] fix: Key the Actor source and version refusals by method and path The refusal of POST /v2/actors/{actorId}/versions and the source fields refused on Actor and version updates were looked up by operation ID, so a renamed ID would have let a body with envVars replace every variable, secret ones included. They now key on method and path like the other rules, and the index refuses a spec that no longer lists them. The source fields are now refused whether or not the schema declares them: the filter only ever stopped refusing a field the API still takes. --- src/tools/api/apify_api_spec.ts | 36 ++++++++++++------------- tests/unit/tools.apify_api_spec.test.ts | 32 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index a0739adce..7bc9f1867 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -117,31 +117,33 @@ export const REFUSED_BODY_FIELDS: ReadonlySet = new Set([ /** Refused fields the API accepts on an operation although its body schema does not declare them. */ const UNDECLARED_REFUSED_BODY_FIELDS = keyRules([['POST /v2/actors', ['pricingInfos', 'actorPermissionLevel']]]); -/** Every rule key, each of which must match an operation of the spec. */ -const RULE_KEYS: readonly string[] = [ - ...UNAVAILABLE_OPERATION_REASONS.keys(), - ...UNDECLARED_REFUSED_BODY_FIELDS.keys(), -]; - /** Body fields of an Actor version that set its source or its environment variables. */ const VERSION_SOURCE_FIELDS = ['sourceType', 'sourceFiles', 'tarballUrl', 'gitRepoUrl', 'gitHubGistUrl', 'envVars']; /** - * Body fields that set an Actor's source, versions, or environment variables, by operation. Most replace - * a whole source or list, so whatever the body leaves out is gone, secret variables included. - * Dedicated source tools own these changes. Like the fields above, they are refused only where the - * operation's schema declares them. The single-variable env var operations stay available. + * Body fields that set an Actor's source, versions, or environment variables, by method and path. Most + * replace a whole source or list, so whatever the body leaves out is gone, secret variables included. + * Unlike the fields above, they are refused whether or not the schema declares them: these bodies are + * not free-form, and the API takes the fields either way. The single-variable env var operations stay + * available. */ -const SOURCE_BODY_FIELDS_BY_OPERATION: ReadonlyMap = new Map([ - ['actor_version_post', VERSION_SOURCE_FIELDS], - ['actor_version_put', VERSION_SOURCE_FIELDS], - ['actors_post', ['versions']], - ['actor_put', ['versions']], +const SOURCE_BODY_FIELDS_BY_OPERATION = keyRules([ + ['POST /v2/actors/{actorId}/versions/{versionNumber}', VERSION_SOURCE_FIELDS], + ['PUT /v2/actors/{actorId}/versions/{versionNumber}', VERSION_SOURCE_FIELDS], + ['POST /v2/actors', ['versions']], + ['PUT /v2/actors/{actorId}', ['versions']], ]); /** Every source field of `SOURCE_BODY_FIELDS_BY_OPERATION`, to tell its refusal from the others. */ export const SOURCE_BODY_FIELDS: ReadonlySet = new Set([...SOURCE_BODY_FIELDS_BY_OPERATION.values()].flat()); +/** Every rule key, each of which must match an operation of the spec. */ +const RULE_KEYS: readonly string[] = [ + ...UNAVAILABLE_OPERATION_REASONS.keys(), + ...UNDECLARED_REFUSED_BODY_FIELDS.keys(), + ...SOURCE_BODY_FIELDS_BY_OPERATION.keys(), +]; + /** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); @@ -309,9 +311,7 @@ export function buildApiOperationIndex(spec: unknown): Map ...[...REFUSED_BODY_FIELDS].filter( (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), ), - ...(SOURCE_BODY_FIELDS_BY_OPERATION.get(operationId) ?? []).filter((field) => - declaredBodyFields.has(field), - ), + ...(SOURCE_BODY_FIELDS_BY_OPERATION.get(ruleKey) ?? []), ], ...resolveAccess(method, path, ruleKey), }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 6b9e74202..0e220a05c 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -86,7 +86,7 @@ describe('buildApiOperationIndex()', () => { 'actorPermissionLevel', 'versions', ]); - // Only the source fields the version schema declares; buildTag stays settable. + // buildTag is not a source field and stays settable. const versionSourceFields = [ 'sourceType', 'sourceFiles', @@ -144,6 +144,36 @@ describe('buildApiOperationIndex()', () => { 'isPublic', 'pricingInfos', 'actorPermissionLevel', + 'versions', + ]); + + const updateVersion = withOperation('/v2/actors/{actorId}/versions/{versionNumber}', 'put', { + ...API_SPEC_FIXTURE.paths['/v2/actors/{actorId}/versions/{versionNumber}'].put, + operationId: 'actorVersion_update', + }); + expect(buildApiOperationIndex(updateVersion).get('actorVersion_update')?.refusedBodyFields).toContain( + 'envVars', + ); + const createVersion = withOperation('/v2/actors/{actorId}/versions', 'post', { + ...API_SPEC_FIXTURE.paths['/v2/actors/{actorId}/versions'].post, + operationId: 'actorVersion_create', + }); + expect(buildApiOperationIndex(createVersion).get('actorVersion_create')?.access).toBe(API_ACCESS.UNAVAILABLE); + }); + + it('refuses the source fields even where the schema does not declare them', () => { + const withoutEnvVars = withOperation('/v2/actors/{actorId}/versions/{versionNumber}', 'put', { + ...API_SPEC_FIXTURE.paths['/v2/actors/{actorId}/versions/{versionNumber}'].put, + requestBody: { content: { 'application/json': { schema: { type: 'object', properties: {} } } } }, + }); + + expect(buildApiOperationIndex(withoutEnvVars).get('actor_version_put')?.refusedBodyFields).toEqual([ + 'sourceType', + 'sourceFiles', + 'tarballUrl', + 'gitRepoUrl', + 'gitHubGistUrl', + 'envVars', ]); }); From e385fb9040e9d83c32b84b5aecacf434aba8ec9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:33:24 +0200 Subject: [PATCH 025/103] fix: Require isSecret when apify-api-write replaces an env var The envVars refusal sends the agent to PUT or POST .../env-vars/{envVarName}, which replace the whole stored variable with the body. A body of {name, value}, the natural call to change a secret variable's value, stored it as plain text with the secret flag gone. Those two operations now refuse a body without a boolean isSecret, and the envVars refusal says to send it. The fixture now has the single-variable env var operations, and a test checks that the operations the refusal names are sent and have write access with no refused fields. --- src/tools/AGENTS.md | 4 +- src/tools/api/apify_api_request.ts | 9 ++- src/tools/api/apify_api_spec.ts | 15 ++++ tests/unit/helpers/apify_api_spec_fixture.ts | 79 ++++++++++++++++++++ tests/unit/tools.apify_api.test.ts | 49 +++++++++++- tests/unit/tools.apify_api_spec.test.ts | 2 +- 6 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 852e72e7f..9ddf2e6af 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -37,7 +37,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. PUT). `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with a reason, plus the body fields the write tool refuses. Fields that set an Actor's source, versions, or whole env var list are refused; the - single-variable env var operations stay callable, since each changes one variable. The rules are + single-variable env var operations stay callable, since each changes one variable; the two that + replace a variable require `isSecret`, since the API stores a variable sent without it as plain + text. The rules are keyed by method and path template, not by operation ID, and a spec that no longer lists an operation a rule is about is refused, like a failed download. The tools take and return method and path only. `apify_api_request.ts` normalizes the real path the agent gives, matches it to a path diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 8dabd488e..b3697fa72 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -341,6 +341,13 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str ); } if (!isRecord(body)) return undefined; + if (operation.replacesEnvVar && typeof body.isSecret !== 'boolean') { + return ( + `${operation.method} ${operation.path} replaces the whole variable, and the API stores a variable ` + + 'sent without isSecret as plain text. Send isSecret: true to keep a secret variable secret, or ' + + 'isSecret: false.' + ); + } const refusedFields = operation.refusedBodyFields.filter((field) => field in body); if (refusedFields.length === 0) return undefined; // One refusal lists every refused field, so the agent does not learn of them one call at a time. @@ -365,7 +372,7 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str 'To add or change one environment variable, call POST ' + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars or PUT ' + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}; each leaves the other ' + - 'variables as they are.', + 'variables as they are. The PUT replaces the whole variable, so send isSecret with it.', ); } // Sending the rest of an otherwise empty body would succeed and change nothing. diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 7bc9f1867..d6425b01f 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -44,6 +44,8 @@ export type ApiOperation = { requestBody?: { isRequired: boolean; schema: unknown }; /** Top-level body fields the write tool refuses for this operation. */ refusedBodyFields: string[]; + /** Set when the body replaces a whole environment variable, so the write tool requires `isSecret`. */ + replacesEnvVar?: true; access: ApiAccess; /** Why the API tools do not call the operation; set exactly when `access` is unavailable. */ unavailableReason?: string; @@ -137,11 +139,23 @@ const SOURCE_BODY_FIELDS_BY_OPERATION = keyRules([ /** Every source field of `SOURCE_BODY_FIELDS_BY_OPERATION`, to tell its refusal from the others. */ export const SOURCE_BODY_FIELDS: ReadonlySet = new Set([...SOURCE_BODY_FIELDS_BY_OPERATION.values()].flat()); +/** + * The operations that replace one environment variable with the body. The API stores a variable sent + * without `isSecret` as plain text, so a secret variable whose value is changed would lose its secrecy. + */ +const ENV_VAR_REPLACE_OPERATIONS: ReadonlySet = new Set( + [ + 'PUT /v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + 'POST /v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + ].map(toRuleKey), +); + /** Every rule key, each of which must match an operation of the spec. */ const RULE_KEYS: readonly string[] = [ ...UNAVAILABLE_OPERATION_REASONS.keys(), ...UNDECLARED_REFUSED_BODY_FIELDS.keys(), ...SOURCE_BODY_FIELDS_BY_OPERATION.keys(), + ...ENV_VAR_REPLACE_OPERATIONS, ]; /** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ @@ -313,6 +327,7 @@ export function buildApiOperationIndex(spec: unknown): Map ), ...(SOURCE_BODY_FIELDS_BY_OPERATION.get(ruleKey) ?? []), ], + ...(ENV_VAR_REPLACE_OPERATIONS.has(ruleKey) && { replacesEnvVar: true }), ...resolveAccess(method, path, ruleKey), }); } diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 514ef9efe..f0aec69ff 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -225,6 +225,81 @@ export const API_SPEC_FIXTURE = { }, }, }, + // The single-variable env var operations, which the envVars refusal points to. + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars': { + get: { + operationId: 'actor_version_envVars_get', + summary: 'Get list of environment variables', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, + post: { + operationId: 'actor_version_envVars_post', + summary: 'Create environment variable', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/EnvVar' } } }, + }, + }, + }, + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}': { + get: { + operationId: 'actor_version_envVar_get', + summary: 'Get environment variable', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'envVarName', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, + put: { + operationId: 'actor_version_envVar_put', + summary: 'Update environment variable', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'envVarName', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/EnvVar' } } }, + }, + }, + post: { + operationId: 'actor_version_envVar_post', + summary: 'Update environment variable (POST)', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'envVarName', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/EnvVar' } } }, + }, + }, + delete: { + operationId: 'actor_version_envVar_delete', + summary: 'Delete environment variable', + tags: ['Actors/Environment variables'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'envVarName', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, + }, '/v2/actors/{actorId}/runs': { post: { operationId: 'actors_runs_post', @@ -494,6 +569,10 @@ export const API_SPEC_FIXTURE = { versions: { type: 'array' }, }, }, + EnvVar: { + type: 'object', + properties: { name: { type: 'string' }, value: { type: 'string' }, isSecret: { type: 'boolean' } }, + }, VersionRequest: { type: 'object', properties: { diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 218ede80d..3d1fbcde4 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -688,7 +688,8 @@ describe('apify-api-write', () => { 'which dedicated source tools or Apify Console change. To add or change one environment ' + 'variable, call POST /v2/actors/{actorId}/versions/{versionNumber}/env-vars or PUT ' + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}; each leaves the other ' + - 'variables as they are. Without the refused fields the body sets nothing, so do not call again with it.', + 'variables as they are. The PUT replaces the whole variable, so send isSecret with it. Without ' + + 'the refused fields the body sets nothing, so do not call again with it.', ], [ { path: '/v2/actors/john~my-actor/versions/0.1', method: 'PUT', body: { sourceFiles: [] } }, @@ -764,6 +765,52 @@ describe('apify-api-write', () => { expect(result.content[0].text).toContain('GET /v2/users/me has read access; this tool has write access.'); }); + it.each(['PUT', 'POST'])( + 'refuses a %s that replaces an env var without isSecret, which would store it as plain text', + async (method) => { + const result = await callTool(apifyApiWrite, { + path: '/v2/actors/john~my-actor/versions/0.1/env-vars/API_KEY', + method, + body: { name: 'API_KEY', value: 'new-value' }, + }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + `${method} /v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName} replaces the whole ` + + 'variable, and the API stores a variable sent without isSecret as plain text. Send isSecret: ' + + 'true to keep a secret variable secret, or isSecret: false.', + ); + expect(requestMock).not.toHaveBeenCalled(); + }, + ); + + it('sends the single-variable env var operations the envVars refusal points to', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: { name: 'API_KEY' } })); + + await callTool(apifyApiWrite, { + path: '/v2/actors/john~my-actor/versions/0.1/env-vars/API_KEY', + method: 'PUT', + body: { name: 'API_KEY', value: 'new-value', isSecret: true }, + }); + await callTool(apifyApiWrite, { + path: '/v2/actors/john~my-actor/versions/0.1/env-vars', + method: 'POST', + body: { name: 'REGION', value: 'eu' }, + }); + + expect(requestMock.mock.calls.map(([config]) => `${config.method} ${config.url}`)).toEqual([ + `PUT ${BASE_URL}/actors/john~my-actor/versions/0.1/env-vars/API_KEY`, + `POST ${BASE_URL}/actors/john~my-actor/versions/0.1/env-vars`, + ]); + const details = await callTool(apifyApiDetails, { + path: '/v2/actors/john~my-actor/versions/0.1/env-vars/API_KEY', + method: 'PUT', + }); + expect(details.structuredContent).toMatchObject({ + operations: [{ access: 'write', refusedBodyFields: [] }], + }); + }); + it('sends a version field that does not replace the source, such as buildTag', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { versionNumber: '0.1', buildTag: 'beta' } })); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 0e220a05c..acac788f9 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(52); + expect(index.size).toBe(58); }); it('resolves parameter references and keeps only path and query parameters', () => { From e160d303e9a41c1d13b7d0e567cfca27cc66476f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:33:46 +0200 Subject: [PATCH 026/103] fix: Report the status of a failed write whose error body is over the limit A write rejected with a 4xx whose error body went over the inline limit got the generic message that the request was sent and to check its effect, which suggests it may have applied. The status is known, so the tool now says the write failed with that status. The generic message stays for an abort without a status. --- src/tools/api/apify_api_request.ts | 7 +++++++ tests/unit/tools.apify_api.test.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index b3697fa72..ebd6b97ab 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -490,6 +490,13 @@ export async function callApiOperation(params: { `${MAX_INLINE_BYTES} bytes, so it is not returned; check the result with an operation with read access.`; return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); } + // The status says the write failed; the generic message would say it may have applied. + if (operation.method !== 'GET' && statusCode !== undefined) { + return respondUserError( + `${operation.method} ${path} failed with HTTP ${statusCode}. Its error body is larger than ` + + `${MAX_INLINE_BYTES} bytes, so it is not returned.`, + ); + } return respondUserError(formatOversizeMessage(operation, path, params.loadedToolNames)); } throw toPlainRequestError(error); diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 3d1fbcde4..e2d070522 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -873,6 +873,26 @@ describe('apify-api-write', () => { ); }); + it('reports a failed write whose error body is over the inline limit with its status', async () => { + requestMock.mockRejectedValue( + new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE', undefined, { + res: { statusCode: 400 }, + }), + ); + + const result = await callTool(apifyApiWrite, { + path: '/v2/datasets/abc', + method: 'PUT', + body: { name: 'leads-2026' }, + }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + `PUT /v2/datasets/abc failed with HTTP 400. Its error body is larger than ${MAX_INLINE_BYTES} bytes, ` + + 'so it is not returned.', + ); + }); + it('says the request was sent when an oversize response has no status', async () => { requestMock.mockRejectedValue( new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE'), From 441568dfdeffc4235b0ff3388a24a8bf0eee9338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:34:04 +0200 Subject: [PATCH 027/103] fix: Mark apify-api-write as open-world The tool starts runs and builds and creates webhooks that call any URL, as the repo's other tools that start runs do, and those are annotated open-world. Clients use the hint to decide when to ask the user before a call. --- src/tools/api/apify_api_write.ts | 3 ++- tests/unit/tools.apify_api.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index bb1885dc8..3fa5837f2 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -81,7 +81,8 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ readOnlyHint: false, destructiveHint: true, idempotentHint: false, - openWorldHint: false, + // It starts runs and builds, and creates webhooks that call any URL, like the tools that start runs. + openWorldHint: true, }, // A body can carry secrets: environment variable values, webhook headers, stored records. So can the // webhooks query parameter of a run, whose webhooks carry headers. diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index e2d070522..493199b2a 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -555,6 +555,14 @@ describe('apify-api-read', () => { describe('apify-api-write', () => { const REQUEST_BASE = { params: undefined, maxContentLength: MAX_INLINE_BYTES, signal: expect.any(AbortSignal) }; + it('is annotated as destructive and open-world, since it starts runs and creates webhooks to any URL', () => { + expect(apifyApiWrite.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + }); + it('sends one request with the body serialized as JSON and returns the response', async () => { const body = { data: { id: 'abc', name: 'leads-2026' } }; requestMock.mockResolvedValue(mockResponse(200, body)); From 3ecf0726c22b78e2e046060f371730f059cd66db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:35:25 +0200 Subject: [PATCH 028/103] fix: Describe the apify-api-write body as any JSON value but a string or null The body field and its refusal said a JSON object or array, but a number or a boolean is sent as it is, such as a key-value store record of 42. --- src/tools/api/apify_api_request.ts | 2 +- src/tools/api/apify_api_write.ts | 4 ++-- tests/unit/tools.apify_api.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index ebd6b97ab..83df7bc94 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -336,7 +336,7 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str // A JSON-encoded string would reach the API as a string, not as the object, and skip the checks below. if (typeof body === 'string' || body === null) { return ( - `Pass the body of ${operation.method} ${operation.path} as a JSON object or array, not as ` + + `Pass the body of ${operation.method} ${operation.path} as JSON, usually an object, not as ` + `${body === null ? 'null' : 'a string'}.` ); } diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 3fa5837f2..2a60d423b 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -32,8 +32,8 @@ const apifyApiWriteArgs = z.object({ .unknown() .optional() .describe( - 'The request body as a JSON object or array, not a JSON-encoded string. It is sent as JSON. ' + - 'Required when the operation needs one; omit it otherwise.', + 'The request body, usually a JSON object; any JSON value but a string or null. Do not pass a ' + + 'JSON-encoded string. Required when the operation needs one; omit it otherwise.', ), }); diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 493199b2a..f0df9e2d1 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -663,12 +663,12 @@ describe('apify-api-write', () => { [{ path: '/v2/datasets/abc', method: 'PUT' }, 'PUT /v2/datasets/{datasetId} needs a request body.'], [ { path: '/v2/datasets/abc', method: 'PUT', body: null }, - 'Pass the body of PUT /v2/datasets/{datasetId} as a JSON object or array, not as null.', + 'Pass the body of PUT /v2/datasets/{datasetId} as JSON, usually an object, not as null.', ], [ // A JSON-encoded string would otherwise get past the refused-field check. { path: '/v2/actors/john~my-actor', method: 'PUT', body: '{"isPublic":true}' }, - 'Pass the body of PUT /v2/actors/{actorId} as a JSON object or array, not as a string.', + 'Pass the body of PUT /v2/actors/{actorId} as JSON, usually an object, not as a string.', ], [ { path: '/v2/actor-runs/run-1/abort', body: { gracefully: true } }, From 20a17750d76a15b14167fdb2e6761c5552af7394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:35:49 +0200 Subject: [PATCH 029/103] fix: Send the agent to Apify Console, not to source tools that do not exist The write tool description, the source-field refusal, and the version-create reason said dedicated source tools change an Actor's source, versions, and env vars. No such tool exists yet, so they now name only Apify Console. --- src/tools/api/apify_api_request.ts | 2 +- src/tools/api/apify_api_spec.ts | 3 +-- src/tools/api/apify_api_write.ts | 2 +- tests/unit/tools.apify_api.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 83df7bc94..94c039caa 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -363,7 +363,7 @@ export function validateRequestBody(operation: ApiOperation, body: unknown): str if (sourceFields.length > 0) { sentences.push( `The API tools do not set ${sourceFields.join(', ')}: they set an Actor's source, versions, or ` + - 'environment variables, which dedicated source tools or Apify Console change.', + 'environment variables, which the user can change in Apify Console.', ); } // Paths, not tool names: this same tool calls them, and each changes one variable. diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index d6425b01f..ab9852d9a 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -99,8 +99,7 @@ const UNAVAILABLE_OPERATION_REASONS = keyRules([ // tool refuses; a create without them fails. Updating a version checks only the fields it sends. [ 'POST /v2/actors/{actorId}/versions', - "It sets the new version's source and environment variables, which dedicated source tools or Apify " + - 'Console change.', + "It sets the new version's source and environment variables, which the user can change in Apify " + 'Console.', ], ]); diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 2a60d423b..42577890e 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -49,7 +49,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { The request is sent once and applies at once. Refused: deletions, synchronous runs, metamorphs, spending limits, run charging, and creating Actor versions; fields that publish an Actor or task, change its pricing or permissions, or change who can read a storage; and fields that set an Actor's - source, versions, or environment variables, which dedicated source tools or Apify Console change. + source, versions, or environment variables, which the user can change in Apify Console. Returns the response body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. Example call: {"path": "/v2/datasets/abc", "method": "PUT", "body": {"name": "leads-2026"}} diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index f0df9e2d1..1831aafdd 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -693,7 +693,7 @@ describe('apify-api-write', () => { body: { envVars: [{ name: 'API_KEY', value: 'x', isSecret: true }] }, }, "The API tools do not set envVars: they set an Actor's source, versions, or environment variables, " + - 'which dedicated source tools or Apify Console change. To add or change one environment ' + + 'which the user can change in Apify Console. To add or change one environment ' + 'variable, call POST /v2/actors/{actorId}/versions/{versionNumber}/env-vars or PUT ' + '/v2/actors/{actorId}/versions/{versionNumber}/env-vars/{envVarName}; each leaves the other ' + 'variables as they are. The PUT replaces the whole variable, so send isSecret with it. Without ' + @@ -750,7 +750,7 @@ describe('apify-api-write', () => { expect(result.content[0].text).toBe( 'The API tools do not set isPublic, whatever the value: publishing, pricing, permission, and sharing ' + "changes need a dedicated tool or Apify Console. The API tools do not set versions: they set an Actor's " + - 'source, versions, or environment variables, which dedicated source tools or Apify Console change. ' + + 'source, versions, or environment variables, which the user can change in Apify Console. ' + 'Remove the refused fields and call again.', ); expect(requestMock).not.toHaveBeenCalled(); From ee969f4e2390a43cde19c04238993a5f3864e292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:35:59 +0200 Subject: [PATCH 030/103] fix: Send the agent to apify-api-details with the method from apify-api-write Like the search and read descriptions: without the method, details returns every operation on the path. --- src/tools/api/apify_api_write.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 42577890e..691796d4b 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -40,7 +40,7 @@ const apifyApiWriteArgs = z.object({ function buildDescription({ hasTool }: ToolDescriptionContext): string { const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? `\nFind the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) - ? `\nGet the operation's parameters and body schema first with ${HELPER_TOOLS.API_DETAILS}.` + ? `\nGet the operation's parameters and body schema first with ${HELPER_TOOLS.API_DETAILS} and the method.` : ''; return dedent` Send a POST or PUT request to the Apify API at a path with its values in it, such as From 936ed1f1c2b12db6742acd4e99e12096d6e1d063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:36:05 +0200 Subject: [PATCH 031/103] refactor: Join the version-create reason into one string --- src/tools/api/apify_api_spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index ab9852d9a..ded5ed5fe 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -99,7 +99,7 @@ const UNAVAILABLE_OPERATION_REASONS = keyRules([ // tool refuses; a create without them fails. Updating a version checks only the fields it sends. [ 'POST /v2/actors/{actorId}/versions', - "It sets the new version's source and environment variables, which the user can change in Apify " + 'Console.', + "It sets the new version's source and environment variables, which the user can change in Apify Console.", ], ]); From e11d6cd83607794e5e69242eee0ae2397c597fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:36:40 +0200 Subject: [PATCH 032/103] test: Give the write fixture's version and run paths their real methods The Actor version and run paths lacked the GET and DELETE methods the published spec has, so method-less write tests passed on paths the real API gives several methods. Those tests now pass the method, and the read-tool hint test uses a real path without GET. --- tests/unit/helpers/apify_api_spec_fixture.ts | 30 ++++++++++++++++++++ tests/unit/tools.apify_api.test.ts | 15 ++++++---- tests/unit/tools.apify_api_spec.test.ts | 2 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index f0aec69ff..2dcdff13e 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -186,6 +186,12 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors/{actorId}/versions': { + get: { + operationId: 'actor_versions_get', + summary: 'Get list of versions', + tags: ['Actors/Actor versions'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, post: { operationId: 'actor_versions_post', summary: 'Create version', @@ -198,6 +204,15 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors/{actorId}/versions/{versionNumber}': { + get: { + operationId: 'actor_version_get', + summary: 'Get version', + tags: ['Actors/Actor versions'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, post: { operationId: 'actor_version_post', summary: 'Update version (POST)', @@ -224,6 +239,15 @@ export const API_SPEC_FIXTURE = { content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionRequest' } } }, }, }, + delete: { + operationId: 'actor_version_delete', + summary: 'Delete version', + tags: ['Actors/Actor versions'], + parameters: [ + { $ref: '#/components/parameters/actorId' }, + { name: 'versionNumber', in: 'path', required: true, schema: { type: 'string' } }, + ], + }, }, // The single-variable env var operations, which the envVars refusal points to. '/v2/actors/{actorId}/versions/{versionNumber}/env-vars': { @@ -301,6 +325,12 @@ export const API_SPEC_FIXTURE = { }, }, '/v2/actors/{actorId}/runs': { + get: { + operationId: 'actor_runs_get', + summary: 'Get list of runs', + tags: ['Actors/Actor runs'], + parameters: [{ $ref: '#/components/parameters/actorId' }], + }, post: { operationId: 'actors_runs_post', summary: 'Run Actor', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 1831aafdd..78d231238 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -396,13 +396,13 @@ describe('apify-api-read', () => { }); it('names the write tool for a path without GET only when the session has it', async () => { - const args = { path: '/v2/actors/john~my-actor/versions/0.1' }; + const args = { path: '/v2/request-queues/q/requests/batch' }; const withWrite = await callTool(apifyApiRead, args); const withoutWrite = await callTool(apifyApiRead, args, [HELPER_TOOLS.API_READ]); expect(withWrite.content[0].text).toBe( - 'The path /v2/actors/john~my-actor/versions/0.1 matches methods POST and PUT, not GET; this tool ' + - `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE} and method POST or PUT.`, + 'The path /v2/request-queues/q/requests/batch matches methods POST and DELETE, not GET; this tool ' + + `sends only GET. Call it with ${HELPER_TOOLS.API_WRITE} and method POST.`, ); expect(withoutWrite.content[0].text).not.toContain(HELPER_TOOLS.API_WRITE); expect(withoutWrite.content[0].text).toContain('No tool in this session has write access.'); @@ -718,16 +718,21 @@ describe('apify-api-write', () => { [ { path: '/v2/actors/john~my-actor/versions', + method: 'POST', body: { versionNumber: '0.2', envVars: [{ name: 'API_KEY', value: 'x' }] }, }, "The API tools do not call POST /v2/actors/john~my-actor/versions. It sets the new version's source", ], [ - { path: '/v2/actors/john~my-actor/runs', query: { forcePermissionLevel: 'FULL_PERMISSIONS' } }, + { + path: '/v2/actors/john~my-actor/runs', + method: 'POST', + query: { forcePermissionLevel: 'FULL_PERMISSIONS' }, + }, 'The API tools do not set forcePermissionLevel, whatever the value', ], [ - { path: '/v2/actors/john~my-actor/runs', query: { waitForFinish: 60 } }, + { path: '/v2/actors/john~my-actor/runs', method: 'POST', query: { waitForFinish: 60 } }, 'call GET /v2/actor-runs/{runId} or GET /v2/actor-builds/{buildId} with waitForFinish; calling this ' + 'operation again starts another one.', ], diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index acac788f9..eeabc7cec 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -16,7 +16,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(58); + expect(index.size).toBe(62); }); it('resolves parameter references and keeps only path and query parameters', () => { From c1aa14f84ffd0390b4e3d3f64b4eba6f993f40a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 00:37:15 +0200 Subject: [PATCH 033/103] docs: Rewrap the api paragraph in src/tools/AGENTS.md --- src/tools/AGENTS.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 9ddf2e6af..7e4e52a48 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -32,22 +32,21 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. `build-actor` (start a build of one version and wait for it); `build_helpers.ts` holds the allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the - operations on a path, and send a request to a path with read access (GET) or write access (POST, - PUT). `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) and decides - each operation's access: read, write, or unavailable with a reason, plus the body fields the write - tool refuses. Fields that set an Actor's source, versions, or whole env var list are refused; the - single-variable env var operations stay callable, since each changes one variable; the two that - replace a variable require `isSecret`, since the API stores a variable sent without it as plain - text. The rules are - keyed by method and path template, not by operation ID, and a spec that no longer lists an - operation a rule is about is refused, like a failed download. The tools take and return method and - path only. `apify_api_request.ts` normalizes the real path the agent gives, matches it to a path - template (more literal segments win), picks the method, checks the query and body, and sends one - request with no retries. The details and call tools log their arguments through - `redactApiCallArgs` (`redactArgs`): an allowlist of path, method, query, and body, with the body - and the `token`, `signature`, and `webhooks` query values redacted, and a query written into the - path cut to `?[REDACTED]`. + - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get + the operations on a path, and send a request to a path with read access (GET) or write access + (POST, PUT). `apify_api_spec.ts` builds the operation index from the spec (cached for an hour) + and decides each operation's access: read, write, or unavailable with a reason, plus the body + fields the write tool refuses. Fields that set an Actor's source, versions, or whole env var + list are refused; the single-variable env var operations stay callable, since each changes one + variable; the two that replace a variable require `isSecret`, since the API stores a variable + sent without it as plain text. The rules are keyed by method and path template, not by operation + ID, and a spec that no longer lists an operation a rule is about is refused, like a failed + download. The tools take and return method and path only. `apify_api_request.ts` normalizes the + real path the agent gives, matches it to a path template (more literal segments win), picks the + method, checks the query and body, and sends one request with no retries. The details and call + tools log their arguments through `redactApiCallArgs` (`redactArgs`): an allowlist of path, + method, query, and body, with the body and the `token`, `signature`, and `webhooks` query values + redacted, and a query written into the path cut to `?[REDACTED]`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). From bc958d35ab6395aca41ea9d68b18622495366f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 08:39:56 +0200 Subject: [PATCH 034/103] refactor: Make apify-api-read a proxy to the API, like apify api The read tool now sends any GET the API accepts, the way the Apify CLI's apify api command does, and refuses nothing. An agent with the token can call the API directly, so the server does not restrict the same API again. - Remove the operation access rules (unavailable operations and their reasons), the refused body fields, the query parameter checks, the waitForFinish cap, the stream refusal, and the path checks. Search and details no longer return access, unavailableReason, or refusedBodyFields. - Take the path as the CLI does: acts, v2/acts, and /v2/acts are the same. The path is sent as written, a query string in it is kept, and the query argument is added after it. Only the origin is checked, so the token never goes to another host. - The read tool no longer needs the spec. It uses it only to name the parameters that narrow a large response and to add the closest paths to a 404, and falls back to the plain message when the spec does not load. - Mask the session token wherever a response echoes it, such as GET /v2/browser-info. --- README.md | 4 +- src/tools/AGENTS.md | 15 +- src/tools/api/apify_api_details.ts | 84 ++-- src/tools/api/apify_api_read.ts | 38 +- src/tools/api/apify_api_request.ts | 341 +++++++--------- src/tools/api/apify_api_search.ts | 6 +- src/tools/api/apify_api_spec.ts | 148 +------ src/tools/structured_output_schemas.ts | 25 +- tests/unit/helpers/apify_api_spec_fixture.ts | 96 +---- tests/unit/tools.apify_api.test.ts | 398 +++++++++---------- tests/unit/tools.apify_api_spec.test.ts | 95 +---- 11 files changed, 404 insertions(+), 846 deletions(-) diff --git a/README.md b/README.md index f638e3239..c1b376615 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them: - **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages. - **Schedules**: Create, inspect, update, and delete schedules that run your Actors and tasks automatically. - **Builds**: Build an Actor version, check the status of a build, and retrieve its build log. -- **Apify API**: Search the Apify API reference, inspect an operation, and call read-only operations. +- **Apify API**: Search the Apify API reference, inspect an operation, and send GET requests to the API. ### Overview of available tools @@ -296,7 +296,7 @@ Legend for the **Enabled by default** column: | `build-actor` | builds | Build an Actor version and wait a bounded time for the build to finish. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to an Apify API path, such as `/v2/actor-runs`. | | +| `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 4fe401d3d..809f35b65 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -33,13 +33,14 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the - operations on a path, and send a GET to a path. `apify_api_spec.ts` builds the operation index from - the spec (cached for an hour) and decides each operation's access: read, write, or unavailable with - a reason. The rules are keyed by method and path template, not by operation ID, and a spec that no - longer lists an operation a rule is about is refused, like a failed download. The tools take and - return method and path only. `apify_api_request.ts` normalizes the real path the agent gives, - matches it to a path template (more literal segments win), checks the query, and sends one request - with no retries. The details and call tools log their arguments through `redactApiCallArgs` + operations on a path, and send a GET to a path. The call tool is a proxy to the API, like + `apify api` in the Apify CLI: it refuses nothing the API accepts. `apify_api_spec.ts` builds the + operation index from the spec (cached for an hour); search and details use it, and a call uses it + only for hints, so a failed download does not stop a call. `apify_api_request.ts` normalizes the + path as the CLI does (`acts`, `v2/acts`, and `/v2/acts` are the same), sends it as written with + the query added after any query string in it, checks only that the URL stays on the API origin, + sends one request with no retries, masks the session token in the response, and adds the closest + spec paths to a 404. The details and call tools log their arguments through `redactApiCallArgs` (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, `signature`, and `webhooks` query values redacted, and a query written into the path cut to `?[REDACTED]`. diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index caaf7b7e2..3a25454c3 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,8 +7,14 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; -import { redactApiCallArgs, resolveMethodOperation, resolvePathOperations } from './apify_api_request.js'; -import { API_METHODS, fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; +import { + findPathOperations, + formatApiPath, + formatList, + normalizeApiPath, + redactApiCallArgs, +} from './apify_api_request.js'; +import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ path: z @@ -16,7 +22,7 @@ const apifyApiDetailsArgs = z.object({ .min(1) .describe( 'The API path, as a template such as /v2/datasets/{datasetId} or with its values in it, ' + - 'such as /v2/datasets/abc.', + 'such as /v2/datasets/abc. datasets/abc, v2/datasets/abc, and /v2/datasets/abc are the same.', ), method: z .enum(API_METHODS) @@ -24,34 +30,11 @@ const apifyApiDetailsArgs = z.object({ .describe('HTTP method of the operation. Omit it to get every operation on the path.'), }); -/** - * A copy of a body schema without the refused fields, at its top level and in its `allOf`/`anyOf`/`oneOf` - * parts, the same places the refusal looks. The agent may not send them, and their schemas cost context. - */ -function omitRefusedFields(schema: unknown, refusedBodyFields: readonly string[]): unknown { - if (!isRecord(schema)) return schema; - const result: Record = { ...schema }; - if (isRecord(schema.properties)) { - result.properties = Object.fromEntries( - Object.entries(schema.properties).filter(([name]) => !refusedBodyFields.includes(name)), - ); - } - if (Array.isArray(schema.required)) { - result.required = schema.required.filter((name) => !refusedBodyFields.includes(name)); - } - for (const key of ['allOf', 'anyOf', 'oneOf']) { - const parts = schema[key]; - if (Array.isArray(parts)) result[key] = parts.map((part) => omitRefusedFields(part, refusedBodyFields)); - } - return result; -} - function buildDescription({ hasTool }: ToolDescriptionContext): string { const findPath = hasTool(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; return dedent` Get the Apify API operations on a path: for each method, its description, path and query - parameters, and the JSON schema of its request body. Also returns each operation's access (read, - write, or unavailable with the reason) and the body fields the API tools refuse to set.${findPath} + parameters, and the JSON schema of its request body.${findPath} Give a method to get only that operation, for example {"path": "/v2/datasets/abc", "method": "PUT"}. USAGE: @@ -87,18 +70,28 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); const index = await fetchApiOperationIndex(); - const matched = resolvePathOperations({ - index, - path: parsed.path, - loadedToolNames: toolArgs.loadedToolNames, - canBeTemplate: true, - }); - if ('error' in matched) return respondUserError(matched.error); - let { operations } = matched; - if (parsed.method) { - const resolved = resolveMethodOperation(matched.path, operations, parsed.method); - if ('error' in resolved) return respondUserError(resolved.error); - operations = [resolved.operation]; + const normalizedPath = normalizeApiPath(parsed.path); + const path = formatApiPath(normalizedPath); + const matched = findPathOperations(index, normalizedPath); + if (matched.length === 0) { + const next = toolArgs.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) + ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` + : ''; + return respondUserError( + `The path ${path} is not in the API spec. A name is written username~name, as in ` + + `/v2/acts/john~my-actor.${next}`, + ); + } + const operations = parsed.method ? matched.filter((operation) => operation.method === parsed.method) : matched; + if (operations.length === 0) { + const noun = matched.length === 1 ? 'method' : 'methods'; + const methods = formatList( + matched.map((operation) => operation.method), + 'and', + ); + return respondUserError( + `The path ${path} has no ${parsed.method} operation; it matches ${noun} ${methods}.`, + ); } const result = { operations: operations.map((operation) => ({ @@ -106,20 +99,11 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ path: operation.path, summary: operation.summary, description: operation.description, - access: operation.access, - ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), parameters: operation.parameters, - ...(operation.requestBody && { - requestBody: { - ...operation.requestBody, - schema: omitRefusedFields(operation.requestBody.schema, operation.refusedBodyFields), - }, - }), - refusedBodyFields: operation.refusedBodyFields, + ...(operation.requestBody && { requestBody: operation.requestBody }), })), }; - const methods = operations.map((operation) => `${operation.method} (${operation.access})`).join(', '); - const summary = `${operations[0].path}: ${methods}.`; + const summary = `${operations[0].path}: ${operations.map((operation) => operation.method).join(', ')}.`; return respondOk([JSON.stringify(result), summary], { structuredContent: result }); }, } as const); diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 1d6160867..a658579c4 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -5,17 +5,8 @@ import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; import type { InternalToolArgs, ToolDescriptionContext, ToolEntry, ToolInputSchema } from '../../types.js'; import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; -import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; -import { - apiCallArgsShape, - callApiOperation, - redactApiCallArgs, - resolvePathOperations, - resolveReadOperation, - validateQueryParams, -} from './apify_api_request.js'; -import { fetchApiOperationIndex } from './apify_api_spec.js'; +import { API_CALL_DESCRIPTION, apiCallArgsShape, callApi, redactApiCallArgs } from './apify_api_request.js'; const apifyApiReadArgs = z.object(apiCallArgsShape); @@ -25,8 +16,8 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { ? `\nGet the operation's query parameters first with ${HELPER_TOOLS.API_DETAILS} and method GET.` : ''; return dedent` - Send a GET request to the Apify API at a path with its values in it, such as /v2/actor-runs/abc. - The server adds the host and the API token: never pass a URL or a token.${findPath}${getParameters} + Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc. + ${API_CALL_DESCRIPTION}${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. @@ -42,7 +33,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Sends a GET to a path of the published Apify API spec, https://docs.apify.com/api/v2. + * Sends a GET to a path of the Apify API, https://docs.apify.com/api/v2, as a proxy. */ export const apifyApiRead: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -64,24 +55,11 @@ export const apifyApiRead: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); - const index = await fetchApiOperationIndex(); - const matched = resolvePathOperations({ - index, - path: parsed.path, - loadedToolNames: toolArgs.loadedToolNames, - }); - if ('error' in matched) return respondUserError(matched.error); - const resolved = resolveReadOperation(matched.path, matched.operations); - if ('error' in resolved) return respondUserError(resolved.error); - const { operation } = resolved; - - const queryError = validateQueryParams(operation, parsed.query); - if (queryError) return respondUserError(queryError); - - return callApiOperation({ + return callApi({ client: toolArgs.apifyClient, - operation, - path: matched.path, + token: toolArgs.apifyToken, + method: 'GET', + path: parsed.path, query: parsed.query, signal: toolArgs.signal, loadedToolNames: toolArgs.loadedToolNames, diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index d3b699017..688680356 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -8,8 +8,8 @@ import { isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; -import type { ApiOperation } from './apify_api_spec.js'; -import { API_ACCESS, isRecord } from './apify_api_spec.js'; +import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; +import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; /** Input fields the read and write tools share. */ export const apiCallArgsShape = { @@ -17,31 +17,33 @@ export const apiCallArgsShape = { .string() .min(1) .describe( - 'The API path with its values in it, for example /v2/datasets/abc/items. A name is written ' + - 'username~name, as in /v2/actors/john~my-actor. Query parameters go in query, not in the path.', + 'The API path with its values in it: acts, v2/acts, and /v2/acts are the same. A name is written ' + + 'username~name, as in /v2/acts/john~my-actor. A query string written into the path is sent too.', ), query: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .optional() - .describe( - 'Query parameters, by name, for example {"limit": 10}. Only parameters the operation declares are accepted.', - ), + .describe('Query parameters, by name, for example {"limit": 10}. Added to a query string in the path.'), }; -/** Query parameters whose values grant access or carry secrets: a storage signature, and webhooks with headers. */ +/** The shared description lines of the read and write tools. */ +export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. +Write the path as acts, v2/acts, or /v2/acts, with its values in it; a name is written username~name. +Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, +so prefer asynchronous runs.`; + +/** Query parameters whose values grant access or carry secrets: a token, a storage signature, and webhooks with headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; -/** Stands in for a logged value that may carry a secret. */ +/** Stands in for a logged or returned value that may carry a secret. */ const REDACTED = '[REDACTED]'; /** * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps the path and * method, the query with the values of secret parameters redacted, and only a marker for the body, - * which can carry environment variable values, webhook headers, or stored records. An undeclared - * `token` is redacted too, since it is logged before the query check refuses it. So is a query - * written into the path, such as a public URL's `?signature=`, which the path check refuses only - * after the arguments are logged. A path or query of the wrong type, which AJV refuses after the - * arguments are logged, is logged only as a marker. + * which can carry environment variable values, webhook headers, or stored records. A query written + * into the path, such as a public URL's `?signature=`, is logged only as a marker. A path or query of + * the wrong type, which AJV refuses after the arguments are logged, is logged only as a marker. */ export function redactApiCallArgs({ path, method, query, body }: Record) { let loggedQuery: unknown; @@ -63,59 +65,36 @@ export function redactApiCallArgs({ path, method, query, body }: Record, segments: string[]): ApiOperation[] { +export function findPathOperations(index: Map, normalizedPath: string): ApiOperation[] { + const segments = stripQuery(normalizedPath).split('/'); let bestLiteralCount = -1; let operations: ApiOperation[] = []; for (const operation of index.values()) { @@ -144,121 +124,40 @@ function findPathOperations(index: Map, segments: string[] return operations; } -/** Words in a sentence, with an Oxford comma: `GET, PUT, and DELETE`. */ -function formatList(words: readonly string[], conjunction: 'and' | 'or'): string { - if (words.length <= 2) return words.join(` ${conjunction} `); - return `${words.slice(0, -1).join(', ')}, ${conjunction} ${words.at(-1)}`; -} - -/** Method names in a sentence: `GET, PUT, and DELETE`. */ -function formatMethodList(operations: readonly ApiOperation[]): string { - return formatList( - operations.map((operation) => operation.method), - 'and', - ); -} - -/** `method POST` or `methods GET and PUT`. */ -function formatMatchedMethods(operations: readonly ApiOperation[]): string { - return `${operations.length === 1 ? 'method' : 'methods'} ${formatMethodList(operations)}`; -} - /** - * The operations on the path template a request path matches, with the path as it is sent, or why - * the path is refused. The refusal names the search tool only when the session has it. + * Up to five path templates closest to a normalized path, best first, scored as the Apify CLI's + * `findClosestEndpoints`. */ -export function resolvePathOperations(params: { - index: Map; - path: string; - loadedToolNames: readonly string[]; - /** Lets the path be a template, such as `/v2/datasets/{datasetId}`; only for describing operations. */ - canBeTemplate?: boolean; -}): { path: string; operations: ApiOperation[] } | { error: string } { - const parsed = parseApiPath(params.path, params.canBeTemplate); - if ('error' in parsed) return parsed; - const path = `${API_PATH_PREFIX}${parsed.segments.join('/')}`; - const operations = findPathOperations(params.index, parsed.segments); - if (operations.length > 0) return { path, operations }; - const next = params.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) - ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` - : ''; - // A template is fine for describing operations, so only the call tools ask for the values. - const nameRule = params.canBeTemplate - ? 'A name is written username~name' - : 'Write the path with its values in it; a name is written username~name'; - return { - error: `No Apify API operation matches the path ${path}. ${nameRule}, as in /v2/actors/john~my-actor.${next}`, - }; -} - -/** The operation among the matched ones with the method, or why there is none. */ -export function resolveMethodOperation( - path: string, - operations: readonly ApiOperation[], - method: string, -): { operation: ApiOperation } | { error: string } { - const operation = operations.find((candidate) => candidate.method === method); - if (operation) return { operation }; - return { error: `The path ${path} has no ${method} operation; it matches ${formatMatchedMethods(operations)}.` }; +export function findClosestApiPaths(index: Map, normalizedPath: string): string[] { + const input = stripQuery(normalizedPath).toLowerCase(); + const inputSegments = input.split('/').filter(Boolean); + const scores = new Map(); + for (const template of new Set([...index.values()].map((operation) => operation.path))) { + const candidate = normalizeApiPath(template).toLowerCase(); + let score = candidate.includes(input) || input.includes(candidate) ? 10 : 0; + const segments = candidate.split('/').filter(Boolean); + for (let position = 0; position < Math.min(inputSegments.length, segments.length); position++) { + if (segments[position] === inputSegments[position]) score += 2; + else if (segments[position].startsWith('{')) score += 1; + } + if (segments.length === inputSegments.length) score += 1; + if (score > 0) scores.set(template, score); + } + return [...scores] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 5) + .map(([template]) => template); } -/** The refusal of an operation the API tools do not call, or `undefined` when they call it. */ -function formatUnavailableMessage(operation: ApiOperation, path: string): string | undefined { - if (operation.access !== API_ACCESS.UNAVAILABLE) return undefined; - return `The API tools do not call ${operation.method} ${path}. ${operation.unavailableReason}`; +/** Words in a sentence, with an Oxford comma: `GET, PUT, and DELETE`. */ +export function formatList(words: readonly string[], conjunction: 'and' | 'or'): string { + if (words.length <= 2) return words.join(` ${conjunction} `); + return `${words.slice(0, -1).join(', ')}, ${conjunction} ${words.at(-1)}`; } -/** The GET operation on the matched path, or why the read tool may not call the path. */ -export function resolveReadOperation( - path: string, - operations: readonly ApiOperation[], -): { operation: ApiOperation } | { error: string } { - const operation = operations.find((candidate) => candidate.method === 'GET'); - if (!operation) { - return { - error: - `The path ${path} matches ${formatMatchedMethods(operations)}, not GET; this tool sends only GET. ` + - 'No tool in this session has write access.', - }; - } - const unavailableMessage = formatUnavailableMessage(operation, path); - return unavailableMessage ? { error: unavailableMessage } : { operation }; -} - -/** Query values the API reads as true. */ -const TRUE_QUERY_VALUES: ReadonlySet = new Set(['true', '1']); - -/** Checks the query against the parameters the operation declares; returns the reason on failure. */ -export function validateQueryParams( - operation: ApiOperation, - query: Record = {}, -): string | undefined { - const declared = operation.parameters.filter((parameter) => parameter.in === 'query'); - const declaredNames = new Set(declared.map((parameter) => parameter.name)); - const unknownNames = Object.keys(query).filter((name) => !declaredNames.has(name)); - if (unknownNames.length > 0) { - return ( - `${operation.method} ${operation.path} does not take the query parameter ${unknownNames.join(', ')}. ` + - `It takes: ${[...declaredNames].join(', ') || 'none'}.` - ); - } - const missingNames = declared - .filter((parameter) => parameter.isRequired && query[parameter.name] === undefined) - .map((parameter) => parameter.name); - if (missingNames.length > 0) return `Missing required query parameter ${missingNames.join(', ')}.`; - // Same cap as the run and build tools: MCP clients stop waiting for a tool call after 60 seconds. - // The API reads the value with parseInt, so "60s" waits 60 seconds. - if (query.waitForFinish !== undefined && Number.parseInt(String(query.waitForFinish), 10) > WAIT_SECS_MAX) { - return `waitForFinish can be at most ${WAIT_SECS_MAX} seconds. Call the operation again to keep waiting.`; - } - // The log operations' stream keeps the request open while the run or build is running. - if (query.stream !== undefined && TRUE_QUERY_VALUES.has(String(query.stream).toLowerCase())) { - return ( - 'stream keeps the request open while the run or build is running, longer than MCP clients wait for ' + - 'a tool call. Call again without stream to get the log so far.' - ); - } - return undefined; +/** The index, or `undefined` when the spec cannot be loaded; for the hints that only help. */ +async function fetchApiOperationIndexIfAvailable(): Promise | undefined> { + return fetchApiOperationIndex().catch(() => undefined); } /** Query parameters that make a response smaller. */ @@ -271,21 +170,47 @@ function findLogToolName(path: string, loadedToolNames: readonly string[]): stri return loadedToolNames.includes(toolName) ? toolName : undefined; } -/** The refusal of a response over the inline limit, with the parameters the operation has to narrow it. */ -function formatOversizeMessage(operation: ApiOperation, path: string, loadedToolNames: readonly string[]): string { - const narrowingNames = operation.parameters +/** + * The message of a response over the inline limit. It names the parameters that narrow the response + * when the path matches an operation of the spec that declares them. + */ +async function formatOversizeMessage( + method: ApiMethod, + normalizedPath: string, + loadedToolNames: readonly string[], +): Promise { + const path = formatApiPath(normalizedPath); + const message = `The response of ${method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned.`; + const index = await fetchApiOperationIndexIfAvailable(); + const operation = index && findPathOperations(index, normalizedPath).find((match) => match.method === method); + const narrowingNames = (operation?.parameters ?? []) .filter((parameter) => parameter.in === 'query' && NARROWING_QUERY_PARAMS.includes(parameter.name)) .map((parameter) => parameter.name); - const logToolName = findLogToolName(operation.path, loadedToolNames); - let next: string; if (narrowingNames.length > 0) { - next = `Narrow the request with the ${formatList(narrowingNames, 'or')} query parameter.`; - } else if (logToolName) { - next = `Get the end of the log with ${logToolName} instead.`; - } else { - next = 'The operation has no query parameter that narrows it, so the API tools cannot return it.'; + return `${message} Narrow the request with the ${formatList(narrowingNames, 'or')} query parameter.`; } - return `The response of ${operation.method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned. ${next}`; + const logToolName = findLogToolName(stripQuery(path), loadedToolNames); + return logToolName ? `${message} Get the end of the log with ${logToolName} instead.` : message; +} + +/** Adds the closest paths of the spec to a 404 error, when the spec is available. */ +async function addClosestPaths(error: ApifyApiError, normalizedPath: string): Promise { + const index = await fetchApiOperationIndexIfAvailable(); + const paths = index ? findClosestApiPaths(index, normalizedPath) : []; + if (paths.length === 0) return; + error.message = `${error.message.replace(/\.$/, '')}. The closest paths in the API spec: ${paths.join(', ')}`; +} + +/** + * A copy of a response body with the session's token replaced, for example in `GET /v2/browser-info`, + * which echoes the request headers. A binary body is returned as it is; it is never shown. + */ +function maskToken(data: unknown, token: string | undefined): unknown { + if (!token || data === undefined || Buffer.isBuffer(data)) return data; + const text = JSON.stringify(data); + // The token as it appears inside a JSON string. + const escapedToken = JSON.stringify(token).slice(1, -1); + return text.includes(escapedToken) ? JSON.parse(text.replaceAll(escapedToken, REDACTED)) : data; } /** @@ -300,7 +225,11 @@ function toPlainRequestError(error: unknown): Error { } /** - * Sends one request to a matched operation's path and returns the response body. + * Sends one request to an API path and returns the response body, like `apify api` in the Apify CLI. + * + * The URL is the client's base URL and the normalized path, with a query string in the path kept + * and the query parameters added after it. The path is not checked or encoded again: the agent + * encodes values. Only the origin is checked, so the token never goes to another host. * * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one * attempt and no retries, since a retried write could apply twice, and apify-client would retry the @@ -310,9 +239,11 @@ function toPlainRequestError(error: unknown): Error { * default User-Agent instead of apify-client's. A non-2xx response is thrown as the `ApifyApiError` * apify-client itself builds, so it gets the usual tool error text and telemetry. */ -export async function callApiOperation(params: { +export async function callApi(params: { client: ApifyClient; - operation: ApiOperation; + /** The session's token, masked in the response. */ + token?: string; + method: ApiMethod; path: string; query?: Record; /** Aborts the request when the client cancels the tool call. */ @@ -320,13 +251,19 @@ export async function callApiOperation(params: { /** The session's tools, to name a dedicated log tool when a log is too large. */ loadedToolNames: readonly string[]; }): Promise { - const { client, operation, path } = params; + const { client, method } = params; + const normalizedPath = normalizeApiPath(params.path); + const path = formatApiPath(normalizedPath); + // `client.baseUrl` already ends with /v2. + const url = `${client.baseUrl}/${normalizedPath}`; + if (new URL(url).origin !== new URL(client.baseUrl).origin) { + return respondUserError(`The path ${path} leads outside the Apify API. Pass a path, not a URL.`); + } let response: AxiosResponse; try { response = await client.httpClient.axios.request({ - // `client.baseUrl` already ends with /v2, the prefix of every indexed path. - url: `${client.baseUrl}${path.slice('/v2'.length)}`, - method: operation.method, + url, + method, params: params.query, maxContentLength: MAX_INLINE_BYTES, signal: params.signal, @@ -334,26 +271,32 @@ export async function callApiOperation(params: { } catch (error) { // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. if (params.signal?.aborted) return respondAborted(); - if (isMaxContentLengthAbort(error)) - return respondUserError(formatOversizeMessage(operation, path, params.loadedToolNames)); + if (isMaxContentLengthAbort(error)) { + return respondUserError(await formatOversizeMessage(method, normalizedPath, params.loadedToolNames)); + } throw toPlainRequestError(error); } - if (response.status >= 300) throw new ApifyApiError(response, 1); + const data = maskToken(response.data, params.token); + if (response.status >= 300) { + const error = new ApifyApiError({ ...response, data }, 1); + if (response.status === 404) await addClosestPaths(error, normalizedPath); + throw error; + } const contentTypeHeader = response.headers['content-type']; const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined; // apify-client parses JSON and text bodies; a binary body stays a Buffer and is not returned. - const isBinary = Buffer.isBuffer(response.data); + const isBinary = Buffer.isBuffer(data); const structuredContent = { - method: operation.method, + method, path, statusCode: response.status, ...(contentType && { contentType }), - data: isBinary || response.data === undefined ? null : response.data, + data: isBinary || data === undefined ? null : data, }; const summary = isBinary - ? `${operation.method} ${path} returned HTTP ${response.status} with a binary body ` + - `(${contentType ?? 'no Content-Type'}, ${(response.data as Buffer).length} bytes), which is not shown.` - : `${operation.method} ${path} returned HTTP ${response.status}.`; + ? `${method} ${path} returned HTTP ${response.status} with a binary body ` + + `(${contentType ?? 'no Content-Type'}, ${(data as Buffer).length} bytes), which is not shown.` + : `${method} ${path} returned HTTP ${response.status}.`; return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); } diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 3b5271740..0100d0301 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -33,15 +33,13 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { const nextStepsSentence = nextSteps.length > 0 ? `\nAfter finding an operation, ${nextSteps.join(', then ')}.` : ''; return dedent` Search the Apify API reference for operations by keywords. Returns each operation's method, path - template, summary, link to the API reference, and access: read, write, or unavailable with the - reason the API tools do not call it.${nextStepsSentence} + template, summary, and link to the API reference.${nextStepsSentence} To call an operation, fill its path template with real values: /v2/datasets/{datasetId}/items becomes /v2/datasets/abc/items, and a name is written username~name. Prefer a dedicated Apify tool when one does what the user asks. USAGE: - Use when no dedicated tool does what the user asks and the Apify API might. - - Tell the user the reason when the operation they need is unavailable. USAGE EXAMPLES: - user_input: List the webhooks on my account @@ -75,8 +73,6 @@ export const apifyApiSearch: ToolEntry = Object.freeze({ method: operation.method, path: operation.path, summary: operation.summary, - access: operation.access, - ...(operation.unavailableReason && { unavailableReason: operation.unavailableReason }), docsUrl: operation.docsUrl, })); const result = { operations }; diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index ea9888df7..bcb846d81 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -2,19 +2,12 @@ import { z } from 'zod'; import { apifyApiOperationsCache } from '../../state.js'; -/** The published Apify API spec: the only list of operations the API tools call. */ +/** The published Apify API spec, for search, details, and choosing a write's method. */ export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; -export const API_ACCESS = { - READ: 'read', - WRITE: 'write', - UNAVAILABLE: 'unavailable', -} as const; -export type ApiAccess = (typeof API_ACCESS)[keyof typeof API_ACCESS]; - /** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ export const API_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; -type ApiMethod = (typeof API_METHODS)[number]; +export type ApiMethod = (typeof API_METHODS)[number]; /** The API reference has one page per operation, at the operation ID in kebab case. */ const API_DOCS_BASE_URL = 'https://docs.apify.com/api/v2'; @@ -31,7 +24,7 @@ export type ApiOperation = { /** The spec's key for the operation. Internal: the tools find operations by method and path. */ operationId: string; method: ApiMethod; - /** Path template, for example `/v2/actors/{actorId}`. */ + /** Path template, for example `/v2/acts/{actorId}`. */ path: string; summary: string; description: string; @@ -42,80 +35,8 @@ export type ApiOperation = { parameters: ApiParameter[]; /** Dereferenced JSON schema of the request body; absent when the operation takes none. */ requestBody?: { isRequired: boolean; schema: unknown }; - /** Top-level body fields the write tool refuses for this operation. */ - refusedBodyFields: string[]; - access: ApiAccess; - /** Why the API tools do not call the operation; set exactly when `access` is unavailable. */ - unavailableReason?: string; }; -const DELETE_REASON = - 'Deletion cannot be undone, so the API tools do not delete. The user can delete it in Apify Console.'; -const SYNC_RUN_REASON = - 'It waits up to 300 seconds for the run to finish, longer than MCP clients wait for a tool call. ' + - 'Start the run with POST /v2/actors/{actorId}/runs or POST /v2/actor-tasks/{actorTaskId}/runs instead.'; -const METAMORPH_REASON = - 'It turns the run into a run of another Actor, which cannot be undone. Only the Actor itself metamorphs its run.'; - -/** Synchronous run paths. Some are GET, but every one of them starts a paid run. */ -const SYNC_RUN_PATH_REGEX = /\/run-sync(-get-dataset-items)?$/; - -const PATH_PLACEHOLDER_REGEX = /\{[^{}]+\}/g; - -/** - * The key of an operation in the rules below: its method and path template, with every placeholder - * written `{}`, such as `POST /v2/actor-runs/{}/charge`. The rules are keyed by path, the API's - * contract, not by operation ID, which is docs metadata and can be renamed; `{}` keeps a renamed - * path parameter from lifting a rule. - */ -function toRuleKey(methodAndPath: string): string { - return methodAndPath.replace(PATH_PLACEHOLDER_REGEX, '{}'); -} - -function keyRules(rules: readonly (readonly [string, Value])[]): ReadonlyMap { - return new Map(rules.map(([methodAndPath, value]) => [toRuleKey(methodAndPath), value])); -} - -const BROWSER_INFO_REASON = - 'It returns the request headers, and with them the API token the server adds to every request.'; - -/** Operations the API tools refuse by method and path, with the reason they report. */ -const UNAVAILABLE_OPERATION_REASONS = keyRules([ - ['PUT /v2/users/me/limits', "It changes the account's spending limits. The user can change them in Apify Console."], - [ - 'POST /v2/actor-runs/{runId}/charge', - 'It charges the user of a pay-per-event run. Only the Actor itself charges for its events.', - ], - ['POST /v2/actor-runs/{runId}/metamorph', METAMORPH_REASON], - ['POST /v2/actors/{actorId}/runs/last/metamorph', METAMORPH_REASON], - ['POST /v2/actor-tasks/{actorTaskId}/runs/last/metamorph', METAMORPH_REASON], - // A proxy test endpoint; its DELETE is refused like every DELETE. - ['GET /v2/browser-info', BROWSER_INFO_REASON], - ['POST /v2/browser-info', BROWSER_INFO_REASON], - ['PUT /v2/browser-info', BROWSER_INFO_REASON], -]); - -/** - * Body fields that publish an Actor or task, change its pricing or permissions, or change who can read - * a storage. They are refused only where the operation's schema declares them: a free-form body, such - * as a key-value store record, can carry the same names as plain data. - */ -export const REFUSED_BODY_FIELDS: ReadonlySet = new Set([ - 'isPublic', - 'pricingInfos', - 'actorPermissionLevel', - 'generalAccess', -]); - -/** Refused fields the API accepts on an operation although its body schema does not declare them. */ -const UNDECLARED_REFUSED_BODY_FIELDS = keyRules([['POST /v2/actors', ['pricingInfos', 'actorPermissionLevel']]]); - -/** Every rule key, each of which must match an operation of the spec. */ -const RULE_KEYS: readonly string[] = [ - ...UNAVAILABLE_OPERATION_REASONS.keys(), - ...UNDECLARED_REFUSED_BODY_FIELDS.keys(), -]; - /** Schema keywords that only cost context. `x-*` vendor extensions are dropped by prefix in `dereference()`. */ const DROPPED_SCHEMA_KEYS: ReadonlySet = new Set(['example', 'examples']); @@ -182,20 +103,6 @@ function dereference(node: unknown, spec: unknown, seenRefs: ReadonlySet return result; } -/** Property names a dereferenced schema declares at its top level, including through `allOf`/`anyOf`/`oneOf`. */ -function extractTopLevelPropertyNames(schema: unknown): Set { - if (!isRecord(schema)) return new Set(); - const names = new Set(isRecord(schema.properties) ? Object.keys(schema.properties) : []); - for (const key of ['allOf', 'anyOf', 'oneOf']) { - const parts = schema[key]; - if (!Array.isArray(parts)) continue; - for (const part of parts) { - for (const name of extractTopLevelPropertyNames(part)) names.add(name); - } - } - return names; -} - /** Lowercase words of a text, splitting camelCase, snake_case, kebab-case, and paths. */ function splitWords(text: string): string[] { return text @@ -205,18 +112,6 @@ function splitWords(text: string): string[] { .filter(Boolean); } -function resolveAccess( - method: ApiMethod, - path: string, - ruleKey: string, -): Pick { - if (method === 'DELETE') return { access: API_ACCESS.UNAVAILABLE, unavailableReason: DELETE_REASON }; - if (SYNC_RUN_PATH_REGEX.test(path)) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: SYNC_RUN_REASON }; - const reason = UNAVAILABLE_OPERATION_REASONS.get(ruleKey); - if (reason) return { access: API_ACCESS.UNAVAILABLE, unavailableReason: reason }; - return { access: method === 'GET' ? API_ACCESS.READ : API_ACCESS.WRITE }; -} - function parseParameters(rawParameters: unknown[] | undefined, spec: unknown): ApiParameter[] { const parameters: ApiParameter[] = []; for (const rawParameter of rawParameters ?? []) { @@ -250,25 +145,18 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ /** * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, * and anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. - * It throws when the spec lists no operation, and when an operation a rule is about is missing from - * it: a moved path would otherwise lift the refusal, so the tools stop until the rules are updated. + * It throws when the spec lists no operation. */ export function buildApiOperationIndex(spec: unknown): Map { const index = new Map(); - // Every operation the spec lists, deprecated or malformed ones too, to check the rules against. - const specRuleKeys = new Set(); const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; for (const method of API_METHODS) { - const ruleKey = toRuleKey(`${method} ${path}`); - if (pathItem[method.toLowerCase()] !== undefined) specRuleKeys.add(ruleKey); const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); if (!parsed.success || parsed.data.deprecated) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); - const declaredBodyFields = extractTopLevelPropertyNames(body?.schema); - const undeclaredRefusedFields = UNDECLARED_REFUSED_BODY_FIELDS.get(ruleKey) ?? []; index.set(operationId, { operationId, method, @@ -279,21 +167,10 @@ export function buildApiOperationIndex(spec: unknown): Map tags: tags ?? [], parameters: parseParameters(parameters, spec), ...(body && { requestBody: body }), - refusedBodyFields: [...REFUSED_BODY_FIELDS].filter( - (field) => declaredBodyFields.has(field) || undeclaredRefusedFields.includes(field), - ), - ...resolveAccess(method, path, ruleKey), }); } } if (index.size === 0) throw new Error('the spec lists no /v2/ operations.'); - const missingRuleKeys = RULE_KEYS.filter((ruleKey) => !specRuleKeys.has(ruleKey)); - if (missingRuleKeys.length > 0) { - throw new Error( - `the spec no longer lists ${missingRuleKeys.join(', ')}, which the API tools have rules for. ` + - 'Update the rules in apify_api_spec.ts.', - ); - } return index; } @@ -313,7 +190,7 @@ async function downloadApiOperationIndex(): Promise> { } catch { throw new Error(`${failure}: the response is not JSON.`); } - // An index without operations, or without an operation a rule refuses, is not cached. + // An index without operations is not cached. try { return buildApiOperationIndex(spec); } catch (error) { @@ -323,8 +200,7 @@ async function downloadApiOperationIndex(): Promise> { /** * The operation index, from the published spec, cached for an hour. Concurrent calls share one - * download. A failed download throws and is not cached: the tools never call an operation the spec - * does not list, and the next call tries again. + * download. A failed download throws and is not cached, and the next call tries again. */ export async function fetchApiOperationIndex(): Promise> { const cached = apifyApiOperationsCache.get(APIFY_API_OPENAPI_URL); @@ -382,8 +258,8 @@ const RUN_SCOPED_PENALTY = 2; * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the * operation ID or path, else 1 in the tags, else 2 when it is a verb for the operation's method. A verb * alone does not match an operation. A run-scoped path loses 2 when no keyword is about runs or tasks. - * Ties go to an available operation, then to the shorter path, so `/v2/datasets/{datasetId}` comes - * before the same operation on a run's default dataset. + * Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes before the same operation on a + * run's default dataset. */ export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { // One-letter terms, such as the s of "run's", match too many words. @@ -407,14 +283,8 @@ export function searchApiOperations(index: Map, query: str const penalty = !isAboutRuns && RUN_SCOPED_PATH_REGEX.test(operation.path) ? RUN_SCOPED_PENALTY : 0; scored.push({ operation, score: score + verbScore - penalty }); } - const isUnavailable = (operation: ApiOperation) => (operation.access === API_ACCESS.UNAVAILABLE ? 1 : 0); return scored - .sort( - (a, b) => - b.score - a.score || - isUnavailable(a.operation) - isUnavailable(b.operation) || - a.operation.path.length - b.operation.path.length, - ) + .sort((a, b) => b.score - a.score || a.operation.path.length - b.operation.path.length) .slice(0, limit) .map(({ operation }) => operation); } diff --git a/src/tools/structured_output_schemas.ts b/src/tools/structured_output_schemas.ts index 4d1e6c11d..095f1d641 100644 --- a/src/tools/structured_output_schemas.ts +++ b/src/tools/structured_output_schemas.ts @@ -878,16 +878,6 @@ export const keyValueStoreRecordOutputSchema = { required: ['keyValueStoreId', 'key', 'value', 'summary'], }; -const apiAccessProperty = { - type: 'string' as const, - enum: ['read', 'write', 'unavailable'], - description: 'read (GET) or write (POST, PUT) access, or unavailable when the API tools do not call it', -}; -const apiUnavailableReasonProperty = { - type: 'string' as const, - description: 'Why the API tools do not call the operation; only when access is unavailable', -}; - /** * Schema for the operations found by apify-api-search. */ @@ -902,11 +892,9 @@ export const apifyApiSearchOutputSchema = { method: { type: 'string', description: 'HTTP method' }, path: { type: 'string', description: 'Path template; each {name} is a path parameter' }, summary: { type: 'string', description: 'What the operation does' }, - access: apiAccessProperty, - unavailableReason: apiUnavailableReasonProperty, docsUrl: { type: 'string', description: "The operation's page in the API reference" }, }, - required: ['method', 'path', 'summary', 'access', 'docsUrl'], + required: ['method', 'path', 'summary', 'docsUrl'], }, }, }, @@ -921,8 +909,6 @@ const apiOperationDetailsSchema = { path: { type: 'string', description: 'Path template; each {name} is a path parameter' }, summary: { type: 'string', description: 'What the operation does' }, description: { type: 'string', description: 'Full description from the API reference' }, - access: apiAccessProperty, - unavailableReason: apiUnavailableReasonProperty, parameters: { type: 'array' as const, items: { @@ -942,18 +928,13 @@ const apiOperationDetailsSchema = { type: 'object' as const, properties: { isRequired: { type: 'boolean', description: 'Whether the body is required' }, - schema: { description: 'JSON schema of the body, sent as JSON, without the refused body fields' }, + schema: { description: 'JSON schema of the body, sent as JSON' }, }, required: ['isRequired', 'schema'], description: 'Only when the operation takes a body', }, - refusedBodyFields: { - type: 'array' as const, - items: { type: 'string' }, - description: 'Body fields the API tools refuse to set for this operation', - }, }, - required: ['method', 'path', 'summary', 'description', 'access', 'parameters', 'refusedBodyFields'], + required: ['method', 'path', 'summary', 'description', 'parameters'], }; /** diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 10cbb0ce8..f2f43cdaa 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -1,8 +1,6 @@ /** * A small OpenAPI document shaped like https://docs.apify.com/api/openapi.json: `$ref` parameters and - * bodies, examples, and `x-*` extensions, and one operation of each kind the API tools treat differently. - * Each path has the methods it has in the published spec, and every operation a refusal rule is - * about is here, since the index refuses a spec without them. + * bodies, examples, and `x-*` extensions. Each path has the methods it has in the published spec. */ export const API_SPEC_FIXTURE = { openapi: '3.1.2', @@ -118,22 +116,6 @@ export const API_SPEC_FIXTURE = { ], }, }, - '/v2/actor-runs/{runId}/metamorph': { - post: { - operationId: 'actorRun_metamorph_post', - summary: 'Metamorph run', - tags: ['Actor runs'], - parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], - }, - }, - '/v2/actor-runs/{runId}/charge': { - post: { - operationId: 'PostChargeRun', - summary: 'Charge events in run', - tags: ['Actor runs'], - parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], - }, - }, '/v2/actor-runs/{runId}/abort': { post: { operationId: 'actorRun_abort_post', @@ -142,7 +124,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], }, }, - '/v2/actors': { + '/v2/acts': { get: { operationId: 'actors_get', summary: 'Get list of Actors', @@ -162,7 +144,7 @@ export const API_SPEC_FIXTURE = { }, }, }, - '/v2/actors/{actorId}': { + '/v2/acts/{actorId}': { get: { operationId: 'actor_get', summary: 'Get Actor', @@ -187,7 +169,7 @@ export const API_SPEC_FIXTURE = { }, // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset // in their summary: search must still rank the storage's own operation first. - '/v2/actors/{actorId}/runs/last/dataset/items': { + '/v2/acts/{actorId}/runs/last/dataset/items': { post: { operationId: 'actor_runs_last_dataset_items_post', summary: "Store items in last run's dataset", @@ -196,7 +178,7 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, }, }, - '/v2/actors/{actorId}/run-sync-get-dataset-items': { + '/v2/acts/{actorId}/run-sync-get-dataset-items': { post: { operationId: 'actor_runSyncGetDatasetItems_post', summary: 'Run Actor synchronously and get dataset items', @@ -205,24 +187,8 @@ export const API_SPEC_FIXTURE = { requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, }, }, - '/v2/actors/{actorId}/runs/last/metamorph': { - post: { - operationId: 'actor_runs_last_metamorph_post', - summary: "Metamorph Actor's last run", - tags: ["Last Actor run's metamorph"], - parameters: [{ $ref: '#/components/parameters/actorId' }], - }, - }, - '/v2/actor-tasks/{actorTaskId}/runs/last/metamorph': { - post: { - operationId: 'actorTask_runs_last_metamorph_post', - summary: "Metamorph Actor task's last run", - tags: ["Last Actor task run's metamorph"], - parameters: [{ name: 'actorTaskId', in: 'path', required: true, schema: { type: 'string' } }], - }, - }, // The last run; a path with a run ID here matches nothing, since its operation is deprecated. - '/v2/actors/{actorId}/runs/last': { + '/v2/acts/{actorId}/runs/last': { get: { operationId: 'actor_runs_last_get', summary: 'Get last run', @@ -230,7 +196,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - '/v2/actors/{actorId}/runs/{runId}': { + '/v2/acts/{actorId}/runs/{runId}': { get: { operationId: 'actors_run_get', summary: 'Get run', @@ -238,7 +204,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - '/v2/actors/{actorId}/run-sync': { + '/v2/acts/{actorId}/run-sync': { post: { operationId: 'actor_runSync_post', summary: 'Run Actor synchronously with input and return output', @@ -253,14 +219,6 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items': { - get: { - operationId: 'actorTask_runSyncGetDatasetItems_get', - summary: 'Run task synchronously and get dataset items', - tags: ['Actor tasks'], - parameters: [{ name: 'actorTaskId', in: 'path', required: true, schema: { type: 'string' } }], - }, - }, '/v2/key-value-stores/{storeId}/records/{recordKey}': { get: { operationId: 'keyValueStore_record_get', @@ -318,15 +276,6 @@ export const API_SPEC_FIXTURE = { '/v2/users/me': { get: { operationId: 'users_me_get', summary: 'Get private user data', tags: ['Users/Private data'] }, }, - '/v2/users/me/limits': { - get: { operationId: 'users_me_limits_get', summary: 'Get limits', tags: ['Users/Usage'] }, - put: { - operationId: 'users_me_limits_put', - summary: 'Update limits', - tags: ['Users/Usage'], - requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, - }, - }, '/v2/webhooks': { get: { operationId: 'webhooks_get', summary: 'Get list of webhooks', tags: ['Webhooks/Webhooks'] }, post: { @@ -336,7 +285,7 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, }, }, - '/v2/actors/{actorId}/webhooks': { + '/v2/acts/{actorId}/webhooks': { get: { operationId: 'actor_webhooks_get', summary: 'Get list of webhooks', @@ -390,13 +339,6 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }], }, }, - // Echoes the request headers, and with them the API token. - '/v2/browser-info': { - get: { operationId: 'tools_browser_info_get', summary: 'Get browser info', tags: ['Tools'] }, - post: { operationId: 'tools_browser_info_post', summary: 'Post browser info', tags: ['Tools'] }, - put: { operationId: 'tools_browser_info_put', summary: 'Put browser info', tags: ['Tools'] }, - delete: { operationId: 'tools_browser_info_delete', summary: 'Delete browser info', tags: ['Tools'] }, - }, '/outside/v2': { get: { operationId: 'outside_get', summary: 'Outside the API' } }, '/v2/broken': { get: { summary: 'No operation ID' } }, }, @@ -422,27 +364,11 @@ export const API_SPEC_FIXTURE = { type: 'object', properties: { name: { type: 'string' }, - generalAccess: { type: 'string' }, example: { type: 'string', description: 'A property that happens to be named example.' }, }, }, - // Like the published spec, it does not declare pricingInfos or actorPermissionLevel. - CreateActorRequest: { - type: 'object', - properties: { name: { type: 'string' }, isPublic: { type: 'boolean' } }, - }, - UpdateActorRequest: { - allOf: [{ $ref: '#/components/schemas/ActorPermissionFields' }], - properties: { - title: { type: 'string' }, - isPublic: { type: 'boolean' }, - pricingInfos: { type: 'array' }, - }, - }, - ActorPermissionFields: { - type: 'object', - properties: { actorPermissionLevel: { type: 'string' } }, - }, + CreateActorRequest: { type: 'object', properties: { name: { type: 'string' } } }, + UpdateActorRequest: { type: 'object', properties: { title: { type: 'string' } } }, }, }, }; diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 9352526d6..4571937c4 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -1,20 +1,19 @@ import { ApifyApiError } from 'apify-client'; -import { AxiosError, AxiosHeaders, CanceledError } from 'axios'; +import axios, { AxiosError, AxiosHeaders, CanceledError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; import { - parseApiPath, + findClosestApiPaths, + findPathOperations, + normalizeApiPath, redactApiCallArgs, - resolvePathOperations, - validateQueryParams, } from '../../src/tools/api/apify_api_request.js'; import { apifyApiSearch } from '../../src/tools/api/apify_api_search.js'; import type * as ApifyApiSpecModule from '../../src/tools/api/apify_api_spec.js'; -import type { ApiOperation } from '../../src/tools/api/apify_api_spec.js'; -import { buildApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; +import { buildApiOperationIndex, fetchApiOperationIndex } from '../../src/tools/api/apify_api_spec.js'; import { apifyApiCallOutputSchema, apifyApiDetailsOutputSchema, @@ -63,154 +62,77 @@ async function callTool(tool: unknown, args: Record, loadedTool beforeEach(() => { requestMock.mockReset(); + vi.mocked(fetchApiOperationIndex).mockClear(); }); -describe('parseApiPath()', () => { - it.each([ - ['/v2/datasets/john~my data/items', ['datasets', 'john~my%20data', 'items']], - ['/v2/datasets/john~my%20data/items', ['datasets', 'john~my%20data', 'items']], - ['/v2/datasets/john%7Emy-data', ['datasets', 'john~my-data']], - ['/v2/key-value-stores/store-1/records/a%2Fb', ['key-value-stores', 'store-1', 'records', 'a%2Fb']], - ['/v2/actor-runs/%3F', ['actor-runs', '%3F']], - ])('normalizes %s, so a value works encoded or not', (path, segments) => { - expect(parseApiPath(path)).toEqual({ segments }); - }); - - it.each([ - ['/datasets/abc', 'The path must start with /v2/'], - ['https://api.apify.com/v2/datasets', 'The path must start with /v2/'], - ['/v2/datasets?limit=1', 'The path cannot hold ? or #. Pass query parameters in query.'], - ['/v2/datasets#items', 'The path cannot hold ? or #.'], - ['/v2/datasets/', 'The path /v2/datasets/ has an empty segment.'], - ['/v2//datasets', 'has an empty segment'], - ['/v2/datasets/../users/me', 'The path cannot have a .. segment.'], - ['/v2/datasets/%2E%2E/users/me', 'The path cannot have a .. segment.'], - ['/v2/datasets/./items', 'The path cannot have a . segment.'], - ['/v2/datasets/%2e/items', 'The path cannot have a . segment.'], - ['/v2/datasets/100%', 'The path segment 100% is not valid URL encoding.'], - ['/v2/datasets/{datasetId}/items', 'Replace {datasetId} in the path with its value.'], - ['/v2/datasets/%7BdatasetId%7D/items', 'Replace {datasetId} in the path with its value.'], - ['/v2/datasets/\ud800', 'The path segment \ud800 is not valid URL encoding.'], - ])('refuses %s', (path, reason) => { - const result = parseApiPath(path); - expect('error' in result && result.error).toContain(reason); - }); - - it('takes a template only when asked to, and keeps its placeholders as they are', () => { - expect(parseApiPath('/v2/datasets/{datasetId}', true)).toEqual({ segments: ['datasets', '{datasetId}'] }); - expect(parseApiPath('/v2/datasets/%7BdatasetId%7D', true)).toEqual({ segments: ['datasets', '{datasetId}'] }); - const withQuery = parseApiPath('/v2/datasets/{datasetId}?limit=1', true); - expect('error' in withQuery && withQuery.error).toBe('The path cannot hold ? or #. Remove the query.'); +/** The URL axios sends for the last request, with the query parameters added. */ +function readSentUrl(): string { + const [config] = requestMock.mock.lastCall as [{ url: string; params?: Record }]; + return axios.getUri({ url: config.url, params: config.params }); +} + +describe('normalizeApiPath()', () => { + it.each(['acts', 'v2/acts', '/v2/acts', 'V2/acts'])('takes %s as the CLI does', (path) => { + expect(normalizeApiPath(path)).toBe('acts'); + }); + + it('removes only one leading slash and one prefix', () => { + expect(normalizeApiPath('//v2/acts')).toBe('/v2/acts'); + expect(normalizeApiPath('/v2/v2/acts')).toBe('v2/acts'); }); }); -describe('resolvePathOperations()', () => { - function resolveIds(path: string): string[] { - const result = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); - return 'error' in result ? [] : result.operations.map((operation) => operation.operationId); +describe('findPathOperations()', () => { + function findIds(path: string): string[] { + return findPathOperations(INDEX, path).map((operation) => operation.operationId); } - it('returns every method on the matched template, with the path as it is sent', () => { - const result = resolvePathOperations({ index: INDEX, path: '/v2/datasets/my data', loadedToolNames: [] }); - - expect(result).toEqual({ - path: '/v2/datasets/my%20data', - operations: [INDEX.get('dataset_get'), INDEX.get('dataset_put'), INDEX.get('dataset_delete')], - }); + it('returns every method on the matched template, for a path with values or a template', () => { + const expected = ['dataset_get', 'dataset_put', 'dataset_delete']; + expect(findIds('datasets/my%20data')).toEqual(expected); + expect(findIds('datasets/{datasetId}')).toEqual(expected); + expect(findIds('datasets/abc?limit=1')).toEqual(expected); }); it('matches a literal segment only to itself and prefers the template with more literal segments', () => { - expect(resolveIds('/v2/users/me')).toEqual(['users_me_get']); - expect(resolveIds('/v2/users/abc')).toEqual(['user_get']); - expect(resolveIds('/v2/request-queues/q/requests/batch')).toEqual([ + expect(findIds('users/me')).toEqual(['users_me_get']); + expect(findIds('users/abc')).toEqual(['user_get']); + expect(findIds('request-queues/q/requests/batch')).toEqual([ 'requestQueue_requests_batch_post', 'requestQueue_requests_batch_delete', ]); - expect(resolveIds('/v2/request-queues/q/requests/r-1')).toEqual([ + expect(findIds('request-queues/q/requests/r-1')).toEqual([ 'requestQueue_request_get', 'requestQueue_request_put', 'requestQueue_request_delete', ]); - expect(resolveIds('/v2/actors/abc/runs/last')).toEqual(['actor_runs_last_get']); - expect(resolveIds('/v2/actors/abc/runs/run-1')).toEqual([]); + expect(findIds('acts/abc/runs/last')).toEqual(['actor_runs_last_get']); + expect(findIds('acts/abc/runs/run-1')).toEqual([]); + expect(findIds('datasets/')).toEqual([]); }); it('matches a concrete path built from each template back to its own operation', () => { for (const operation of INDEX.values()) { - const path = operation.path.replace(/\{[^}]+\}/g, 'value-1'); - const result = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); + const path = normalizeApiPath(operation.path.replace(/\{[^}]+\}/g, 'value-1')); + const operations = findPathOperations(INDEX, path); - const { operations } = result as { operations: ApiOperation[] }; expect(operations).toContainEqual(operation); expect(operations.filter((matched) => matched.path !== operation.path)).toEqual([]); } }); - - it('explains username~name on no match and names the search tool only when the session has it', () => { - const path = '/v2/actors/john/my-actor'; - const withSearch = resolvePathOperations({ index: INDEX, path, loadedToolNames: [HELPER_TOOLS.API_SEARCH] }); - const withoutSearch = resolvePathOperations({ index: INDEX, path, loadedToolNames: [] }); - - const message = - 'No Apify API operation matches the path /v2/actors/john/my-actor. Write the path with its values ' + - 'in it; a name is written username~name, as in /v2/actors/john~my-actor.'; - expect(withSearch).toEqual({ error: `${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.` }); - expect(withoutSearch).toEqual({ error: message }); - }); - - it('echoes a template as it was written and does not ask a template for its values', () => { - const result = resolvePathOperations({ - index: INDEX, - path: '/v2/datasets/{datasetId}/nothing', - loadedToolNames: [], - canBeTemplate: true, - }); - - expect(result).toEqual({ - error: - 'No Apify API operation matches the path /v2/datasets/{datasetId}/nothing. A name is written ' + - 'username~name, as in /v2/actors/john~my-actor.', - }); - }); }); -describe('validateQueryParams()', () => { - it('accepts declared parameters', () => { - expect(validateQueryParams(INDEX.get('dataset_items_get')!, { format: 'json', limit: 5 })).toBeUndefined(); - }); - - it('refuses a parameter the operation does not declare, such as a token', () => { - expect(validateQueryParams(INDEX.get('dataset_items_get')!, { format: 'json', token: 'secret' })).toBe( - 'GET /v2/datasets/{datasetId}/items does not take the query parameter token. It takes: limit, format.', - ); - }); - - it('refuses a call without a required parameter', () => { - expect(validateQueryParams(INDEX.get('dataset_items_get')!, { limit: 5 })).toBe( - 'Missing required query parameter format.', - ); - }); - - it('caps waitForFinish below the tool-call timeout, reading it as the API does', () => { - const actorRunGet = INDEX.get('actorRun_get')!; - expect(validateQueryParams(actorRunGet, { waitForFinish: 45 })).toBeUndefined(); - expect(validateQueryParams(actorRunGet, { waitForFinish: 60 })).toContain('at most 45 seconds'); - expect(validateQueryParams(actorRunGet, { waitForFinish: '60s' })).toContain('at most 45 seconds'); - }); - - it.each([true, 'true', '1', 1])('refuses stream %j, which keeps the log request open', (stream) => { - expect(validateQueryParams(INDEX.get('actorRun_log_get')!, { stream })).toContain( - 'Call again without stream to get the log so far.', - ); - }); +describe('findClosestApiPaths()', () => { + it('ranks the paths as the CLI does and returns at most five', () => { + const paths = findClosestApiPaths(INDEX, 'datasets/abc/itemz'); - it('accepts a false stream', () => { - expect(validateQueryParams(INDEX.get('actorRun_log_get')!, { stream: false })).toBeUndefined(); + expect(paths.slice(0, 2)).toEqual(['/v2/datasets/{datasetId}/items', '/v2/datasets/{datasetId}']); + expect(paths.length).toBeLessThanOrEqual(5); }); }); describe('apify-api-search', () => { - it('returns the matching operations by method and path, with their access and docs page', async () => { + it('returns the matching operations by method and path, with their docs page', async () => { const result = await callTool(apifyApiSearch, { query: 'delete dataset', limit: 1 }); expectSchemaConformingStructuredContent(result, apifyApiSearchOutputSchema); @@ -220,8 +142,6 @@ describe('apify-api-search', () => { method: 'DELETE', path: '/v2/datasets/{datasetId}', summary: 'Delete dataset', - access: 'unavailable', - unavailableReason: expect.stringContaining('Deletion cannot be undone'), docsUrl: 'https://docs.apify.com/api/v2/dataset-delete', }, ], @@ -237,64 +157,47 @@ describe('apify-api-search', () => { }); describe('apify-api-details', () => { - it('returns every operation on a real path when no method is given', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/datasets/abc' }); - - expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); - const { operations } = result.structuredContent as { operations: { method: string; path: string }[] }; - expect(operations.map(({ method, path }) => `${method} ${path}`)).toEqual([ - 'GET /v2/datasets/{datasetId}', - 'PUT /v2/datasets/{datasetId}', - 'DELETE /v2/datasets/{datasetId}', - ]); - expect(result.content[1].text).toBe('/v2/datasets/{datasetId}: GET (read), PUT (write), DELETE (unavailable).'); - }); + it.each(['datasets/abc', 'v2/datasets/abc', '/v2/datasets/abc'])( + 'returns every operation on %s when no method is given', + async (path) => { + const result = await callTool(apifyApiDetails, { path }); + + expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); + const { operations } = result.structuredContent as { operations: { method: string; path: string }[] }; + expect(operations.map(({ method, path: template }) => `${method} ${template}`)).toEqual([ + 'GET /v2/datasets/{datasetId}', + 'PUT /v2/datasets/{datasetId}', + 'DELETE /v2/datasets/{datasetId}', + ]); + expect(result.content[1].text).toBe('/v2/datasets/{datasetId}: GET, PUT, DELETE.'); + }, + ); it('returns only the operation with the method, for a path template too', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/actors/{actorId}', method: 'PUT' }); + const result = await callTool(apifyApiDetails, { path: '/v2/acts/{actorId}', method: 'PUT' }); expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); expect(result.structuredContent).toEqual({ operations: [ - expect.objectContaining({ + { method: 'PUT', - path: '/v2/actors/{actorId}', - access: 'write', + path: '/v2/acts/{actorId}', + summary: 'Update Actor', + description: '', parameters: [expect.objectContaining({ name: 'actorId', in: 'path', isRequired: true })], - requestBody: expect.objectContaining({ isRequired: false }), - refusedBodyFields: ['isPublic', 'pricingInfos', 'actorPermissionLevel'], - }), + requestBody: { + isRequired: false, + schema: { type: 'object', properties: { title: { type: 'string' } } }, + }, + }, ], }); }); - it('leaves the refused fields out of the body schema it returns', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/actors/abc', method: 'PUT' }); - - const [operation] = (result.structuredContent as { operations: { requestBody: { schema: unknown } }[] }) - .operations; - expect(operation.requestBody.schema).toEqual({ - allOf: [{ type: 'object', properties: {} }], - properties: { title: { type: 'string' } }, - }); - // The index keeps the full schema. - expect(INDEX.get('actor_put')?.requestBody?.schema).toMatchObject({ - properties: { isPublic: { type: 'boolean' }, pricingInfos: { type: 'array' } }, - }); - }); - - it('refuses a method the path does not have and lists the ones it has', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/abc/abort', method: 'GET' }); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe( - 'The path /v2/actor-runs/abc/abort has no GET operation; it matches method POST.', - ); - }); - - it('echoes a template path with its braces in a refusal', async () => { + it('lists the methods the path has when it has not the one asked for', async () => { const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/{runId}/abort', method: 'GET' }); + expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe( 'The path /v2/actor-runs/{runId}/abort has no GET operation; it matches method POST.', ); @@ -309,13 +212,18 @@ describe('apify-api-details', () => { }); }); - it('names the search tool on an unknown path only when the session has it', async () => { - const withSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }); - const withoutSearch = await callTool(apifyApiDetails, { path: '/v2/nope' }, [HELPER_TOOLS.API_DETAILS]); + it('names the search tool on a path not in the spec only when the session has it', async () => { + const withSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }); + const withoutSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }, [ + HELPER_TOOLS.API_DETAILS, + ]); + const message = + 'The path /v2/acts/john/my-actor is not in the API spec. A name is written username~name, as in ' + + '/v2/acts/john~my-actor.'; expectSoftFailInvalidInput(withSearch); - expect(withSearch.content[0].text).toContain(`Find the path with ${HELPER_TOOLS.API_SEARCH}.`); - expect(withoutSearch.content[0].text).not.toContain(HELPER_TOOLS.API_SEARCH); + expect(withSearch.content[0].text).toBe(`${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.`); + expect(withoutSearch.content[0].text).toBe(message); }); }); @@ -348,64 +256,113 @@ describe('apify-api-read', () => { expect(result.content[1].text).toBe('GET /v2/datasets/abc/items returned HTTP 200.'); }); - it('sends a value with a space encoded and keeps ~ as it is', async () => { + it.each(['acts', 'v2/acts', '/v2/acts', 'V2/acts'])('sends %s to /v2/acts', async (path) => { requestMock.mockResolvedValue(mockResponse(200, { data: {} })); - const result = await callTool(apifyApiRead, { path: '/v2/datasets/john~my data' }); - - expect(requestMock).toHaveBeenCalledWith( - expect.objectContaining({ url: `${BASE_URL}/datasets/john~my%20data` }), - ); - expect(result.structuredContent).toMatchObject({ path: '/v2/datasets/john~my%20data' }); - }); - - it.each([ - [ - '/v2/actor-runs/run-1/abort', - 'The path /v2/actor-runs/run-1/abort matches method POST, not GET; this tool sends only GET. ' + - 'No tool in this session has write access.', - ], - [ - '/v2/actors/abc/run-sync', - 'The API tools do not call GET /v2/actors/abc/run-sync. It waits up to 300 seconds', - ], - [ - '/v2/browser-info', - 'The API tools do not call GET /v2/browser-info. It returns the request headers, and with them the API token', - ], - ['/v2/nope', 'No Apify API operation matches the path /v2/nope.'], - ['/v2/datasets/{datasetId}', 'Replace {datasetId} in the path with its value.'], - ['/v2/datasets/abc?limit=1', 'Pass query parameters in query.'], - ])('refuses %s without a request', async (path, reason) => { const result = await callTool(apifyApiRead, { path }); - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toContain(reason); - expect(requestMock).not.toHaveBeenCalled(); + expect(readSentUrl()).toBe(`${BASE_URL}/acts`); + expect(result.structuredContent).toMatchObject({ path: '/v2/acts' }); }); - it('refuses an undeclared query parameter without a request', async () => { - const result = await callTool(apifyApiRead, { path: '/v2/datasets/abc', query: { token: 'secret' } }); + it('sends the path as it is written, without encoding it again', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe( - 'GET /v2/datasets/{datasetId} does not take the query parameter token. It takes: none.', + await callTool(apifyApiRead, { path: 'key-value-stores/john~store/records/a%2Fb' }); + + expect(readSentUrl()).toBe(`${BASE_URL}/key-value-stores/john~store/records/a%2Fb`); + }); + + it('keeps a query string written into the path and adds the query parameters after it', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); + + const result = await callTool(apifyApiRead, { path: 'datasets/abc/items?format=json', query: { limit: 1 } }); + + expect(readSentUrl()).toBe(`${BASE_URL}/datasets/abc/items?format=json&limit=1`); + expect(result.structuredContent).toMatchObject({ path: '/v2/datasets/abc/items?format=json' }); + }); + + it.each(['https://evil.example/steal', '//evil.example/steal', 'user:pass@evil.example/steal', '\\\\evil.example'])( + 'sends %s to the API host, never to another one', + async (path) => { + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'page-not-found', message: 'Not found' } }), + ); + + await callTool(apifyApiRead, { path }).catch(() => undefined); + + expect(new URL(readSentUrl()).origin).toBe(new URL(BASE_URL).origin); + }, + ); + + it('sends a path the spec does not list, without loading the spec', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: { ok: true } })); + + const result = await callTool(apifyApiRead, { path: 'not-in-spec/abc' }); + + expect(readSentUrl()).toBe(`${BASE_URL}/not-in-spec/abc`); + expect(result.structuredContent).toMatchObject({ statusCode: 200, data: { data: { ok: true } } }); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + + it('masks the session token wherever the response echoes it', async () => { + requestMock.mockResolvedValue( + mockResponse(200, { data: { headers: { authorization: 'Bearer test-token' }, echo: ['test-token'] } }), ); - expect(requestMock).not.toHaveBeenCalled(); + + const result = await callTool(apifyApiRead, { path: 'browser-info' }); + + expect(result.structuredContent).toMatchObject({ + data: { data: { headers: { authorization: 'Bearer [REDACTED]' }, echo: ['[REDACTED]'] } }, + }); + expect(JSON.stringify(result)).not.toContain('test-token'); + }); + + it('masks nothing when the session has no token', async () => { + const body = { data: { headers: { authorization: 'Bearer ' } } }; + requestMock.mockResolvedValue(mockResponse(200, body)); + const context = stubToolCallContext({ path: 'browser-info' }, stubClient); + context.apifyToken = ''; + + const result = (await (apifyApiRead as HelperTool).call(context)) as TextToolResult; + + expect(result.structuredContent).toMatchObject({ data: body }); }); it('throws a non-2xx response as the ApifyApiError apify-client builds', async () => { requestMock.mockResolvedValue( - mockResponse(404, { error: { type: 'record-not-found', message: 'Dataset was not found' } }), + mockResponse(400, { error: { type: 'invalid-input', message: 'Invalid limit' } }), ); const call = callTool(apifyApiRead, { path: '/v2/datasets/abc' }); + await expect(call).rejects.toBeInstanceOf(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 400, type: 'invalid-input', message: 'Invalid limit' }); + }); + + it('adds the closest paths of the spec to a 404', async () => { + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), + ); + + const call = callTool(apifyApiRead, { path: 'datasets/abc/itemz' }); + await expect(call).rejects.toBeInstanceOf(ApifyApiError); await expect(call).rejects.toMatchObject({ statusCode: 404, - type: 'record-not-found', - message: 'Dataset was not found', + type: 'page-not-found', + message: `Page not found. The closest paths in the API spec: ${findClosestApiPaths(INDEX, 'datasets/abc/itemz').join(', ')}`, + }); + }); + + it('returns the plain 404 when the spec cannot be loaded', async () => { + vi.mocked(fetchApiOperationIndex).mockRejectedValueOnce(new Error('Failed to load the Apify API operations')); + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), + ); + + await expect(callTool(apifyApiRead, { path: 'datasets/abc/itemz' })).rejects.toMatchObject({ + message: 'Page not found.', }); }); @@ -439,8 +396,21 @@ describe('apify-api-read', () => { expect(withLogTool.content[0].text).toContain( `Get the end of the log with ${HELPER_TOOLS.ACTOR_RUNS_LOG} instead.`, ); - expect(withoutLogTool.content[0].text).toContain( - 'The operation has no query parameter that narrows it, so the API tools cannot return it.', + expect(withoutLogTool.content[0].text).toBe( + `The response of GET /v2/actor-runs/abc/log is larger than ${MAX_INLINE_BYTES} bytes, so it is not ` + + 'returned.', + ); + }); + + it('gives the plain message for a large body when the spec cannot be loaded', async () => { + vi.mocked(fetchApiOperationIndex).mockRejectedValueOnce(new Error('Failed to load the Apify API operations')); + requestMock.mockRejectedValue(buildOversizeError(200)); + + const result = await callTool(apifyApiRead, { path: 'datasets/abc/items' }); + + expect(result.content[0].text).toBe( + `The response of GET /v2/datasets/abc/items is larger than ${MAX_INLINE_BYTES} bytes, so it is not ` + + 'returned.', ); }); @@ -478,7 +448,7 @@ describe('apify-api-read', () => { ); }); - it('logs a storage signature, webhooks, or an undeclared token only as redacted', () => { + it('logs a token, a storage signature, or webhooks only as redacted', () => { const args = { path: '/v2/datasets/abc/items', query: { format: 'json', signature: 'sig-secret', webhooks: 'W3t9XQ==', token: 'token-secret' }, diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 807000253..4a3ff7b53 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - API_ACCESS, APIFY_API_OPENAPI_URL, buildApiOperationIndex, searchApiOperations, @@ -16,7 +15,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(48); + expect(index.size).toBe(37); }); it('resolves parameter references and keeps only path and query parameters', () => { @@ -44,48 +43,8 @@ describe('buildApiOperationIndex()', () => { expect(index.get('actorRun_abort_post')?.requestBody).toBeUndefined(); }); - it('gives GET read access and POST and PUT write access', () => { - expect(index.get('dataset_get')?.access).toBe(API_ACCESS.READ); - expect(index.get('dataset_put')?.access).toBe(API_ACCESS.WRITE); - expect(index.get('actorRun_abort_post')?.access).toBe(API_ACCESS.WRITE); - }); - - it.each([ - ['dataset_delete', 'Deletion cannot be undone'], - ['actor_runSync_get', 'waits up to 300 seconds'], - ['actorTask_runSyncGetDatasetItems_get', 'waits up to 300 seconds'], - ['users_me_limits_put', 'spending limits'], - ['PostChargeRun', 'Only the Actor itself charges'], - ['actorRun_metamorph_post', 'cannot be undone'], - ['actor_runs_last_metamorph_post', 'cannot be undone'], - ['actorTask_runs_last_metamorph_post', 'cannot be undone'], - ['actor_runSyncGetDatasetItems_post', 'waits up to 300 seconds'], - ['actor_runSync_post', 'waits up to 300 seconds'], - ['tools_browser_info_get', 'the API token'], - ['tools_browser_info_post', 'the API token'], - ['tools_browser_info_put', 'the API token'], - ])('makes %s unavailable with the reason', (operationId, reason) => { - const operation = index.get(operationId); - expect(operation?.access).toBe(API_ACCESS.UNAVAILABLE); - expect(operation?.unavailableReason).toContain(reason); - }); - - it('refuses publishing, pricing, permission, and sharing fields only where the body schema declares them', () => { - expect(index.get('actor_put')?.refusedBodyFields).toEqual(['isPublic', 'pricingInfos', 'actorPermissionLevel']); - expect(index.get('dataset_put')?.refusedBodyFields).toEqual(['generalAccess']); - // The API takes these on create, although the create schema does not declare them. - expect(index.get('actors_post')?.refusedBodyFields).toEqual([ - 'isPublic', - 'pricingInfos', - 'actorPermissionLevel', - ]); - // A record body is free-form: a record may hold an isPublic key as plain data. - expect(index.get('keyValueStore_record_put')?.refusedBodyFields).toEqual([]); - }); - it('links each operation to its page in the API reference, at the operation ID in kebab case', () => { expect(index.get('actorRun_abort_post')?.docsUrl).toBe('https://docs.apify.com/api/v2/actor-run-abort-post'); - expect(index.get('PostChargeRun')?.docsUrl).toBe('https://docs.apify.com/api/v2/post-charge-run'); expect(index.get('keyValueStore_record_put')?.docsUrl).toBe( 'https://docs.apify.com/api/v2/key-value-store-record-put', ); @@ -95,56 +54,6 @@ describe('buildApiOperationIndex()', () => { expect(() => buildApiOperationIndex({})).toThrow('the spec lists no /v2/ operations.'); expect(() => buildApiOperationIndex('not a spec')).toThrow('the spec lists no /v2/ operations.'); }); - - /** The fixture with the operation at the path and method replaced. */ - function withOperation(path: string, method: string, operation: Record | undefined) { - const paths: Record> = structuredClone(API_SPEC_FIXTURE.paths); - paths[path] = { ...paths[path], [method]: operation }; - return { ...API_SPEC_FIXTURE, paths }; - } - - it('keys the refusals by method and path, not by the operation ID or the parameter names', () => { - const renamed = buildApiOperationIndex( - withOperation('/v2/actor-runs/{runId}/charge', 'post', { - operationId: 'actorRun_charge_post', - summary: 'Charge events in run', - }), - ); - expect(renamed.get('actorRun_charge_post')?.access).toBe(API_ACCESS.UNAVAILABLE); - - const paths: Record = structuredClone(API_SPEC_FIXTURE.paths); - paths['/v2/actor-runs/{id}/metamorph'] = paths['/v2/actor-runs/{runId}/metamorph']; - delete paths['/v2/actor-runs/{runId}/metamorph']; - const reparameterized = buildApiOperationIndex({ ...API_SPEC_FIXTURE, paths }); - expect(reparameterized.get('actorRun_metamorph_post')?.access).toBe(API_ACCESS.UNAVAILABLE); - - const createActor = withOperation('/v2/actors', 'post', { - ...API_SPEC_FIXTURE.paths['/v2/actors'].post, - operationId: 'actor_create', - }); - expect(buildApiOperationIndex(createActor).get('actor_create')?.refusedBodyFields).toEqual([ - 'isPublic', - 'pricingInfos', - 'actorPermissionLevel', - ]); - }); - - it('throws when an operation a rule is about is missing, rather than lift the rule', () => { - const moved = withOperation('/v2/users/me/limits', 'put', undefined); - - expect(() => buildApiOperationIndex(moved)).toThrow( - 'the spec no longer lists PUT /v2/users/me/limits, which the API tools have rules for.', - ); - }); - - it('checks the rules against a deprecated operation too', () => { - const deprecated = withOperation('/v2/users/me/limits', 'put', { - ...API_SPEC_FIXTURE.paths['/v2/users/me/limits'].put, - deprecated: true, - }); - - expect(buildApiOperationIndex(deprecated).has('users_me_limits_put')).toBe(false); - }); }); describe('searchApiOperations()', () => { @@ -175,7 +84,7 @@ describe('searchApiOperations()', () => { expect(searchIds('rename')).toEqual([]); }); - it("ranks a storage's own operation above a run's copy of it and above an unavailable one", () => { + it("ranks a storage's own operation above a run's copy of it and above a synchronous run", () => { // Both competitors name the dataset in their summary; the storage's own operation does not. const ids = searchIds('add items to a dataset'); expect(ids[0]).toBe('dataset_items_post'); From 14de78bb1b23c37070e572939104b917ded9cf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 09:07:52 +0200 Subject: [PATCH 035/103] fix: Look up acts paths as actors and keep secrets out of API errors The published spec lists Actor paths only under /v2/actors, so an agent that followed the acts examples got no spec match for any Actor path. - Teach the path forms with actors, v2/actors, and /v2/actors, and look up a legacy acts path as actors for details, the narrowing hint, and the 404 suggestions. The request is still sent as written. - Put the test spec back to the /v2/actors paths of the published spec, and drop the query parameters that only served the removed refusals. - Build the API error without the query written into the path, so a signature or token there does not reach the error log on a 5xx. - Mask the token in a binary error body too, since apify-client reads it into the error message. - Wait at most 5 seconds, and not past a cancelled call, for the spec when it only adds a hint. - Keep the origin check as an internal assertion: the URL starts with the base URL's /v2/, so a path cannot lead to another host. - Name the method in the search description instead of read access. --- src/tools/AGENTS.md | 17 +++-- src/tools/api/apify_api_details.ts | 2 +- src/tools/api/apify_api_request.ts | 75 +++++++++++++------ src/tools/api/apify_api_search.ts | 2 +- src/tools/api/apify_api_spec.ts | 2 +- tests/unit/helpers/apify_api_spec_fixture.ts | 28 +++---- tests/unit/tools.apify_api.test.ts | 78 ++++++++++++++++++-- 7 files changed, 148 insertions(+), 56 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 809f35b65..ec74fc897 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -36,14 +36,15 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. operations on a path, and send a GET to a path. The call tool is a proxy to the API, like `apify api` in the Apify CLI: it refuses nothing the API accepts. `apify_api_spec.ts` builds the operation index from the spec (cached for an hour); search and details use it, and a call uses it - only for hints, so a failed download does not stop a call. `apify_api_request.ts` normalizes the - path as the CLI does (`acts`, `v2/acts`, and `/v2/acts` are the same), sends it as written with - the query added after any query string in it, checks only that the URL stays on the API origin, - sends one request with no retries, masks the session token in the response, and adds the closest - spec paths to a 404. The details and call tools log their arguments through `redactApiCallArgs` - (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, - `signature`, and `webhooks` query values redacted, and a query written into the path cut to - `?[REDACTED]`. + only for hints, waiting at most a few seconds, so a failed download does not stop a call. + `apify_api_request.ts` normalizes the path as the CLI does (`actors`, `v2/actors`, and `/v2/actors` + are the same), sends it as written with the query added after any query string in it, and + looks up a legacy `acts` path as `actors`, the prefix the spec lists. It asserts that the URL stays + on the API origin, sends one request with no retries, masks the session token in the response, + keeps a query in the path out of the error it throws, and adds the closest spec paths to a 404. + The details and call tools log their arguments through `redactApiCallArgs` (`redactArgs`): an + allowlist of path, method, query, and body, with the body and the `token`, `signature`, and + `webhooks` query values redacted, and a query written into the path cut to `?[REDACTED]`. - `docs/` — search and fetch Apify docs. - `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor. - `widgets/` — the `*-widget` tool variants (apps mode only). diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 3a25454c3..113ef47ac 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -79,7 +79,7 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ : ''; return respondUserError( `The path ${path} is not in the API spec. A name is written username~name, as in ` + - `/v2/acts/john~my-actor.${next}`, + `/v2/actors/john~my-actor.${next}`, ); } const operations = parsed.method ? matched.filter((operation) => operation.method === parsed.method) : matched; diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 688680356..c00eba7bf 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -1,3 +1,5 @@ +import { setTimeout as sleep } from 'node:timers/promises'; + import { ApifyApiError } from 'apify-client'; import type { AxiosResponse } from 'axios'; import { z } from 'zod'; @@ -17,8 +19,9 @@ export const apiCallArgsShape = { .string() .min(1) .describe( - 'The API path with its values in it: acts, v2/acts, and /v2/acts are the same. A name is written ' + - 'username~name, as in /v2/acts/john~my-actor. A query string written into the path is sent too.', + 'The API path with its values in it: actors, v2/actors, and /v2/actors are the same. A name is ' + + 'written username~name, as in /v2/actors/john~my-actor. A query string written into the path is ' + + 'sent too.', ), query: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -28,7 +31,7 @@ export const apiCallArgsShape = { /** The shared description lines of the read and write tools. */ export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. -Write the path as acts, v2/acts, or /v2/acts, with its values in it; a name is written username~name. +Write the path as actors, v2/actors, or /v2/actors, with its values in it; a name is written username~name. Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, so prefer asynchronous runs.`; @@ -66,8 +69,8 @@ export function redactApiCallArgs({ path, method, query, body }: Record, normalizedPath: string): ApiOperation[] { - const segments = stripQuery(normalizedPath).split('/'); + const segments = toSpecPath(normalizedPath).split('/'); let bestLiteralCount = -1; let operations: ApiOperation[] = []; for (const operation of index.values()) { @@ -129,7 +140,7 @@ export function findPathOperations(index: Map, normalizedP * `findClosestEndpoints`. */ export function findClosestApiPaths(index: Map, normalizedPath: string): string[] { - const input = stripQuery(normalizedPath).toLowerCase(); + const input = toSpecPath(normalizedPath).toLowerCase(); const inputSegments = input.split('/').filter(Boolean); const scores = new Map(); for (const template of new Set([...index.values()].map((operation) => operation.path))) { @@ -155,9 +166,18 @@ export function formatList(words: readonly string[], conjunction: 'and' | 'or'): return `${words.slice(0, -1).join(', ')}, ${conjunction} ${words.at(-1)}`; } -/** The index, or `undefined` when the spec cannot be loaded; for the hints that only help. */ -async function fetchApiOperationIndexIfAvailable(): Promise | undefined> { - return fetchApiOperationIndex().catch(() => undefined); +/** How long a hint waits for the spec, so a stalled download does not hold back the API's answer. */ +const HINT_SPEC_TIMEOUT_MS = 5_000; + +/** + * The index, or `undefined` when the spec cannot be loaded within a few seconds or the call is + * cancelled; for the hints that only help. + */ +async function fetchApiOperationIndexIfAvailable(signal?: AbortSignal): Promise | undefined> { + return Promise.race([ + fetchApiOperationIndex(), + sleep(HINT_SPEC_TIMEOUT_MS, undefined, { signal, ref: false }), + ]).catch(() => undefined); } /** Query parameters that make a response smaller. */ @@ -178,10 +198,11 @@ async function formatOversizeMessage( method: ApiMethod, normalizedPath: string, loadedToolNames: readonly string[], + signal?: AbortSignal, ): Promise { const path = formatApiPath(normalizedPath); const message = `The response of ${method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned.`; - const index = await fetchApiOperationIndexIfAvailable(); + const index = await fetchApiOperationIndexIfAvailable(signal); const operation = index && findPathOperations(index, normalizedPath).find((match) => match.method === method); const narrowingNames = (operation?.parameters ?? []) .filter((parameter) => parameter.in === 'query' && NARROWING_QUERY_PARAMS.includes(parameter.name)) @@ -194,8 +215,8 @@ async function formatOversizeMessage( } /** Adds the closest paths of the spec to a 404 error, when the spec is available. */ -async function addClosestPaths(error: ApifyApiError, normalizedPath: string): Promise { - const index = await fetchApiOperationIndexIfAvailable(); +async function addClosestPaths(error: ApifyApiError, normalizedPath: string, signal?: AbortSignal): Promise { + const index = await fetchApiOperationIndexIfAvailable(signal); const paths = index ? findClosestApiPaths(index, normalizedPath) : []; if (paths.length === 0) return; error.message = `${error.message.replace(/\.$/, '')}. The closest paths in the API spec: ${paths.join(', ')}`; @@ -203,10 +224,16 @@ async function addClosestPaths(error: ApifyApiError, normalizedPath: string): Pr /** * A copy of a response body with the session's token replaced, for example in `GET /v2/browser-info`, - * which echoes the request headers. A binary body is returned as it is; it is never shown. + * which echoes the request headers. A binary body is masked too, since apify-client reads an error + * body that stays a Buffer into the error message. */ function maskToken(data: unknown, token: string | undefined): unknown { - if (!token || data === undefined || Buffer.isBuffer(data)) return data; + if (!token || data === undefined) return data; + if (Buffer.isBuffer(data)) { + // The token is ASCII, so a latin1 round trip keeps every other byte as it is. + const bytes = data.toString('latin1'); + return bytes.includes(token) ? Buffer.from(bytes.replaceAll(token, REDACTED), 'latin1') : data; + } const text = JSON.stringify(data); // The token as it appears inside a JSON string. const escapedToken = JSON.stringify(token).slice(1, -1); @@ -229,7 +256,7 @@ function toPlainRequestError(error: unknown): Error { * * The URL is the client's base URL and the normalized path, with a query string in the path kept * and the query parameters added after it. The path is not checked or encoded again: the agent - * encodes values. Only the origin is checked, so the token never goes to another host. + * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host. * * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one * attempt and no retries, since a retried write could apply twice, and apify-client would retry the @@ -256,9 +283,9 @@ export async function callApi(params: { const path = formatApiPath(normalizedPath); // `client.baseUrl` already ends with /v2. const url = `${client.baseUrl}/${normalizedPath}`; - if (new URL(url).origin !== new URL(client.baseUrl).origin) { - return respondUserError(`The path ${path} leads outside the Apify API. Pass a path, not a URL.`); - } + // An assertion: nothing after the base URL's `/v2/` can change the host that gets the token. + if (new URL(url).origin !== new URL(client.baseUrl).origin) + throw new Error(`The URL ${url} is not on the API host.`); let response: AxiosResponse; try { response = await client.httpClient.axios.request({ @@ -272,14 +299,18 @@ export async function callApi(params: { // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. if (params.signal?.aborted) return respondAborted(); if (isMaxContentLengthAbort(error)) { - return respondUserError(await formatOversizeMessage(method, normalizedPath, params.loadedToolNames)); + return respondUserError( + await formatOversizeMessage(method, normalizedPath, params.loadedToolNames, params.signal), + ); } throw toPlainRequestError(error); } const data = maskToken(response.data, params.token); if (response.status >= 300) { - const error = new ApifyApiError({ ...response, data }, 1); - if (response.status === 404) await addClosestPaths(error, normalizedPath); + // Without the query written into the path: apify-client puts the URL into the error's path and + // stack, and a 5xx error is logged, so a signature or token there would reach the log. + const error = new ApifyApiError({ ...response, data, config: { ...response.config, url: stripQuery(url) } }, 1); + if (response.status === 404) await addClosestPaths(error, normalizedPath, params.signal); throw error; } diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 0100d0301..eb834b547 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -24,7 +24,7 @@ const apifyApiSearchArgs = z.object({ }); function buildDescription({ hasTool }: ToolDescriptionContext): string { - const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for read access`].filter(Boolean); + const callTools = [hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for a GET`].filter(Boolean); const nextSteps = [ hasTool(HELPER_TOOLS.API_DETAILS) && `get its parameters with ${HELPER_TOOLS.API_DETAILS} by its method and path`, diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index bcb846d81..ac7b01b0d 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -24,7 +24,7 @@ export type ApiOperation = { /** The spec's key for the operation. Internal: the tools find operations by method and path. */ operationId: string; method: ApiMethod; - /** Path template, for example `/v2/acts/{actorId}`. */ + /** Path template, for example `/v2/actors/{actorId}`. */ path: string; summary: string; description: string; diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index f2f43cdaa..b5a756257 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -84,10 +84,7 @@ export const API_SPEC_FIXTURE = { operationId: 'actorRun_get', summary: 'Get run', tags: ['Actor runs'], - parameters: [ - { name: 'runId', in: 'path', required: true, schema: { type: 'string' } }, - { name: 'waitForFinish', in: 'query', schema: { type: 'number' } }, - ], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], }, put: { operationId: 'actorRun_put', @@ -108,12 +105,7 @@ export const API_SPEC_FIXTURE = { operationId: 'actorRun_log_get', summary: 'Get log', tags: ['Logs'], - parameters: [ - { name: 'runId', in: 'path', required: true, schema: { type: 'string' } }, - { name: 'stream', in: 'query', schema: { type: 'boolean' } }, - { name: 'download', in: 'query', schema: { type: 'boolean' } }, - { name: 'raw', in: 'query', schema: { type: 'boolean' } }, - ], + parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], }, }, '/v2/actor-runs/{runId}/abort': { @@ -124,7 +116,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'runId', in: 'path', required: true, schema: { type: 'string' } }], }, }, - '/v2/acts': { + '/v2/actors': { get: { operationId: 'actors_get', summary: 'Get list of Actors', @@ -144,7 +136,7 @@ export const API_SPEC_FIXTURE = { }, }, }, - '/v2/acts/{actorId}': { + '/v2/actors/{actorId}': { get: { operationId: 'actor_get', summary: 'Get Actor', @@ -169,7 +161,7 @@ export const API_SPEC_FIXTURE = { }, // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset // in their summary: search must still rank the storage's own operation first. - '/v2/acts/{actorId}/runs/last/dataset/items': { + '/v2/actors/{actorId}/runs/last/dataset/items': { post: { operationId: 'actor_runs_last_dataset_items_post', summary: "Store items in last run's dataset", @@ -178,7 +170,7 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, }, }, - '/v2/acts/{actorId}/run-sync-get-dataset-items': { + '/v2/actors/{actorId}/run-sync-get-dataset-items': { post: { operationId: 'actor_runSyncGetDatasetItems_post', summary: 'Run Actor synchronously and get dataset items', @@ -188,7 +180,7 @@ export const API_SPEC_FIXTURE = { }, }, // The last run; a path with a run ID here matches nothing, since its operation is deprecated. - '/v2/acts/{actorId}/runs/last': { + '/v2/actors/{actorId}/runs/last': { get: { operationId: 'actor_runs_last_get', summary: 'Get last run', @@ -196,7 +188,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - '/v2/acts/{actorId}/runs/{runId}': { + '/v2/actors/{actorId}/runs/{runId}': { get: { operationId: 'actors_run_get', summary: 'Get run', @@ -204,7 +196,7 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - '/v2/acts/{actorId}/run-sync': { + '/v2/actors/{actorId}/run-sync': { post: { operationId: 'actor_runSync_post', summary: 'Run Actor synchronously with input and return output', @@ -285,7 +277,7 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, }, }, - '/v2/acts/{actorId}/webhooks': { + '/v2/actors/{actorId}/webhooks': { get: { operationId: 'actor_webhooks_get', summary: 'Get list of webhooks', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 4571937c4..1a6530dce 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -106,11 +106,17 @@ describe('findPathOperations()', () => { 'requestQueue_request_put', 'requestQueue_request_delete', ]); - expect(findIds('acts/abc/runs/last')).toEqual(['actor_runs_last_get']); - expect(findIds('acts/abc/runs/run-1')).toEqual([]); + expect(findIds('actors/abc/runs/last')).toEqual(['actor_runs_last_get']); + expect(findIds('actors/abc/runs/run-1')).toEqual([]); expect(findIds('datasets/')).toEqual([]); }); + it('looks up a legacy acts path as the actors path the spec lists', () => { + expect(findIds('acts/john~my-actor')).toEqual(['actor_get', 'actor_put', 'actor_delete']); + expect(findIds('acts/abc/runs/last?token=x')).toEqual(findIds('actors/abc/runs/last')); + expect(findIds('actsx/abc')).toEqual([]); + }); + it('matches a concrete path built from each template back to its own operation', () => { for (const operation of INDEX.values()) { const path = normalizeApiPath(operation.path.replace(/\{[^}]+\}/g, 'value-1')); @@ -129,6 +135,12 @@ describe('findClosestApiPaths()', () => { expect(paths.slice(0, 2)).toEqual(['/v2/datasets/{datasetId}/items', '/v2/datasets/{datasetId}']); expect(paths.length).toBeLessThanOrEqual(5); }); + + it('ranks a legacy acts path as the actors path', () => { + expect(findClosestApiPaths(INDEX, 'acts/abc/runs/lastt')).toEqual( + findClosestApiPaths(INDEX, 'actors/abc/runs/lastt'), + ); + }); }); describe('apify-api-search', () => { @@ -174,14 +186,14 @@ describe('apify-api-details', () => { ); it('returns only the operation with the method, for a path template too', async () => { - const result = await callTool(apifyApiDetails, { path: '/v2/acts/{actorId}', method: 'PUT' }); + const result = await callTool(apifyApiDetails, { path: '/v2/actors/{actorId}', method: 'PUT' }); expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); expect(result.structuredContent).toEqual({ operations: [ { method: 'PUT', - path: '/v2/acts/{actorId}', + path: '/v2/actors/{actorId}', summary: 'Update Actor', description: '', parameters: [expect.objectContaining({ name: 'actorId', in: 'path', isRequired: true })], @@ -194,6 +206,12 @@ describe('apify-api-details', () => { }); }); + it('returns the actors operations for a legacy acts path', async () => { + const result = await callTool(apifyApiDetails, { path: 'acts/john~my-actor' }); + + expect(result.content[1].text).toBe('/v2/actors/{actorId}: GET, PUT, DELETE.'); + }); + it('lists the methods the path has when it has not the one asked for', async () => { const result = await callTool(apifyApiDetails, { path: '/v2/actor-runs/{runId}/abort', method: 'GET' }); @@ -220,7 +238,7 @@ describe('apify-api-details', () => { const message = 'The path /v2/acts/john/my-actor is not in the API spec. A name is written username~name, as in ' + - '/v2/acts/john~my-actor.'; + '/v2/actors/john~my-actor.'; expectSoftFailInvalidInput(withSearch); expect(withSearch.content[0].text).toBe(`${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.`); expect(withoutSearch.content[0].text).toBe(message); @@ -228,6 +246,10 @@ describe('apify-api-details', () => { }); describe('apify-api-read', () => { + it('is annotated as read-only', () => { + expect(apifyApiRead.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + }); + it('sends one GET to the path and returns the body as the API sends it', async () => { const body = { data: { total: 1, items: [{ id: 'abc' }] } }; requestMock.mockResolvedValue(mockResponse(200, body)); @@ -318,6 +340,28 @@ describe('apify-api-read', () => { expect(JSON.stringify(result)).not.toContain('test-token'); }); + it('masks the session token in a text body', async () => { + requestMock.mockResolvedValue(mockResponse(200, 'Authorization: Bearer test-token', 'text/plain')); + + const result = await callTool(apifyApiRead, { path: 'browser-info' }); + + expect(result.structuredContent).toMatchObject({ data: 'Authorization: Bearer [REDACTED]' }); + expect(JSON.stringify(result)).not.toContain('test-token'); + }); + + it.each([ + ['JSON', { error: { type: 'invalid-input', message: 'Bearer test-token rejected' } }, 'application/json'], + ['binary', Buffer.from('{"error":{"message":"Bearer test-token rejected"}}'), 'application/octet-stream'], + ])('masks the session token in a %s error body', async (_kind, body, contentType) => { + requestMock.mockResolvedValue(mockResponse(400, body, contentType)); + + const error = await callTool(apifyApiRead, { path: 'datasets/abc' }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ApifyApiError); + expect(error).toMatchObject({ statusCode: 400, message: 'Bearer [REDACTED] rejected' }); + expect(JSON.stringify(error, Object.getOwnPropertyNames(error))).not.toContain('test-token'); + }); + it('masks nothing when the session has no token', async () => { const body = { data: { headers: { authorization: 'Bearer ' } } }; requestMock.mockResolvedValue(mockResponse(200, body)); @@ -340,6 +384,17 @@ describe('apify-api-read', () => { await expect(call).rejects.toMatchObject({ statusCode: 400, type: 'invalid-input', message: 'Invalid limit' }); }); + it('keeps a query written into the path out of the thrown error, whose stack a 5xx logs', async () => { + requestMock.mockResolvedValue(mockResponse(500, { error: { type: 'internal-error', message: 'Failed' } })); + + const error = await callTool(apifyApiRead, { path: 'datasets/abc?signature=sig-secret' }).catch( + (thrown: unknown) => thrown, + ); + + expect(error).toMatchObject({ statusCode: 500, path: '/v2/datasets/abc' }); + expect((error as Error).stack).not.toContain('sig-secret'); + }); + it('adds the closest paths of the spec to a 404', async () => { requestMock.mockResolvedValue( mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), @@ -366,6 +421,19 @@ describe('apify-api-read', () => { }); }); + it('does not wait for a stalled spec download for the 404 hint once the call is cancelled', async () => { + vi.mocked(fetchApiOperationIndex).mockReturnValueOnce(new Promise(() => undefined)); + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), + ); + const controller = new AbortController(); + controller.abort(); + const context = stubToolCallContext({ path: 'datasets/abc/itemz' }, stubClient); + context.signal = controller.signal; + + await expect((apifyApiRead as HelperTool).call(context)).rejects.toMatchObject({ message: 'Page not found.' }); + }); + /** The abort axios throws for a body over `maxContentLength`; Node's request keeps the response status. */ function buildOversizeError(statusCode: number) { return new AxiosError(`maxContentLength size of ${MAX_INLINE_BYTES} exceeded`, 'ERR_BAD_RESPONSE', undefined, { From 6ebf6132f1feb044244ef363c9f84f1474b89cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 09:09:10 +0200 Subject: [PATCH 036/103] fix: Ask for the method when the spec cannot be loaded to choose it A write without a method failed with the raw spec download error, which did not tell the agent that giving the method avoids the spec. It now returns a user error that asks for the method. A test also covers a legacy acts path, whose method comes from the actors path in the spec. --- src/tools/api/apify_api_write.ts | 4 +++- tests/unit/tools.apify_api.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index e565b190c..45ce5991c 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -57,7 +57,9 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { * method needs the spec. */ async function inferMethod(path: string): Promise<{ method: ApiMethod } | { error: string }> { - const operations = findPathOperations(await fetchApiOperationIndex(), normalizeApiPath(path)); + const index = await fetchApiOperationIndex().catch(() => undefined); + if (!index) return { error: 'The API spec could not be loaded to choose the method; specify the method.' }; + const operations = findPathOperations(index, normalizeApiPath(path)); if (operations.length === 0) return { error: 'The path is not in the API spec; specify the method.' }; if (operations.length > 1) { const methods = formatList( diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index c02cb7b31..bab2f666b 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -644,6 +644,27 @@ describe('apify-api-write', () => { }); }); + it('uses the method of the actors path for a legacy acts path', async () => { + requestMock.mockResolvedValue(mockResponse(201, { data: {} })); + + await callTool(apifyApiWrite, { path: 'acts/john~my-actor/runs/last/dataset/items', body: [] }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })); + expect(readSentUrl()).toBe(`${BASE_URL}/acts/john~my-actor/runs/last/dataset/items`); + }); + + it('asks for the method when the spec cannot be loaded to choose it', async () => { + vi.mocked(fetchApiOperationIndex).mockRejectedValueOnce(new Error('Failed to load the Apify API operations')); + + const result = await callTool(apifyApiWrite, { path: 'actor-runs/run-1/abort' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + 'The API spec could not be loaded to choose the method; specify the method.', + ); + expect(requestMock).not.toHaveBeenCalled(); + }); + it.each([ ['datasets/abc', 'The path matches methods GET, PUT, and DELETE; specify which one to call the endpoint with.'], [ From b7b0fd14eef22e990220706c1fb9d0b4572d4739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 09:10:46 +0200 Subject: [PATCH 037/103] feat: Index PATCH operations in the API spec The write tool is a proxy like apify api, which sends PATCH too. The published spec has no PATCH operation today, but indexing it keeps search, details, and method choice working if one is added. --- src/tools/api/apify_api_spec.ts | 3 ++- tests/unit/tools.apify_api_spec.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index ac7b01b0d..47333235c 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -6,7 +6,7 @@ import { apifyApiOperationsCache } from '../../state.js'; export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; /** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ -export const API_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const; +export const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; export type ApiMethod = (typeof API_METHODS)[number]; /** The API reference has one page per operation, at the operation ID in kebab case. */ @@ -237,6 +237,7 @@ const METHOD_VERBS: Record = { GET: ['get', 'list', 'read'], POST: ['add', 'create', 'push', 'start'], PUT: ['update', 'set', 'rename', 'change'], + PATCH: ['update', 'patch', 'change'], DELETE: ['delete', 'remove'], }; diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 4a3ff7b53..fd2f999b2 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -18,6 +18,14 @@ describe('buildApiOperationIndex()', () => { expect(index.size).toBe(37); }); + it('indexes a PATCH operation, which the published spec does not have yet', () => { + const spec = { + paths: { '/v2/things/{thingId}': { patch: { operationId: 'thing_patch', summary: 'Patch thing' } } }, + }; + + expect(buildApiOperationIndex(spec).get('thing_patch')?.method).toBe('PATCH'); + }); + it('resolves parameter references and keeps only path and query parameters', () => { expect(index.get('dataset_items_get')?.parameters).toEqual([ { From 0aaf1ee08476a3fc5a2dc2a4579afeff633178b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 09:11:57 +0200 Subject: [PATCH 038/103] feat: Send PATCH from apify-api-write, and never a GET The write tool is a proxy like apify api, so it now takes every write method: POST, PUT, PATCH, and DELETE. Without a method it used the path's only method in the spec, which sent a GET for a GET-only path such as users/me. It now refuses that case and names apify-api-read when the session has it, so reads stay with the read tool. --- README.md | 2 +- src/tools/AGENTS.md | 2 +- src/tools/api/apify_api_search.ts | 2 +- src/tools/api/apify_api_write.ts | 30 ++++++++++++++++++++++-------- tests/unit/tools.apify_api.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a108fcf72..3f12c5a5d 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Legend for the **Enabled by default** column: | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | | `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. | | -| `apify-api-write` | api | Send a POST, PUT, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. | | +| `apify-api-write` | api | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index f6e85d0d4..e46812292 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -33,7 +33,7 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. allowlisted build result shape, the build start and wait calls (the wait reports progress), the shared `waitSecs` field, the shared build response and the by-status next-step text. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the - operations on a path, and send a GET, or a POST, PUT, or DELETE, to a path. The call tools are + operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The call tools are proxies to the API, like `apify api` in the Apify CLI: they refuse nothing the API accepts. `apify_api_spec.ts` builds the operation index from the spec (cached for an hour); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 8f10f628b..bed6323d7 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -26,7 +26,7 @@ const apifyApiSearchArgs = z.object({ function buildDescription({ hasTool }: ToolDescriptionContext): string { const callTools = [ hasTool(HELPER_TOOLS.API_READ) && `${HELPER_TOOLS.API_READ} for a GET`, - hasTool(HELPER_TOOLS.API_WRITE) && `${HELPER_TOOLS.API_WRITE} for a POST, PUT, or DELETE`, + hasTool(HELPER_TOOLS.API_WRITE) && `${HELPER_TOOLS.API_WRITE} for a POST, PUT, PATCH, or DELETE`, ].filter(Boolean); const nextSteps = [ hasTool(HELPER_TOOLS.API_DETAILS) && diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 45ce5991c..c2e4ff9cb 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -22,9 +22,11 @@ import { fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiWriteArgs = z.object({ path: apiCallArgsShape.path, method: z - .enum(['POST', 'PUT', 'DELETE']) + .enum(['POST', 'PUT', 'PATCH', 'DELETE']) .optional() - .describe('HTTP method: POST, PUT, or DELETE. Omit it only when the path has one method in the API spec.'), + .describe( + 'HTTP method: POST, PUT, PATCH, or DELETE. Omit it only when the path has one method in the API spec.', + ), query: apiCallArgsShape.query, body: z.unknown().optional().describe('The request body: any JSON value, usually an object, sent as JSON.'), }); @@ -35,7 +37,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { ? `\nGet the operation's parameters and body schema first with ${HELPER_TOOLS.API_DETAILS} and the method.` : ''; return dedent` - Send a POST, PUT, or DELETE request to the Apify API at a path, such as /v2/datasets/abc. + Send a POST, PUT, PATCH, or DELETE request to the Apify API at a path, such as /v2/datasets/abc. ${API_CALL_DESCRIPTION}${findPath}${getParameters} The request is sent once and applies at once. Returns the response body as the API sends it; a body over ${MAX_INLINE_BYTES} bytes is not returned. @@ -54,9 +56,12 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * The only method of the path in the spec, or why the agent must give one. Only a call without a - * method needs the spec. + * method needs the spec. A path whose only method is GET is sent to the read tool, never as a GET here. */ -async function inferMethod(path: string): Promise<{ method: ApiMethod } | { error: string }> { +async function inferMethod( + path: string, + loadedToolNames: readonly string[], +): Promise<{ method: Exclude } | { error: string }> { const index = await fetchApiOperationIndex().catch(() => undefined); if (!index) return { error: 'The API spec could not be loaded to choose the method; specify the method.' }; const operations = findPathOperations(index, normalizeApiPath(path)); @@ -68,11 +73,18 @@ async function inferMethod(path: string): Promise<{ method: ApiMethod } | { erro ); return { error: `The path matches methods ${methods}; specify which one to call the endpoint with.` }; } - return { method: operations[0].method }; + const [{ method }] = operations; + if (method === 'GET') { + const readTool = loadedToolNames.includes(HELPER_TOOLS.API_READ) + ? `; call it with ${HELPER_TOOLS.API_READ}` + : ''; + return { error: `The path has only the GET method, which this tool does not send${readTool}.` }; + } + return { method }; } /** - * Sends a POST, PUT, or DELETE to a path of the Apify API, https://docs.apify.com/api/v2, as a proxy. + * Sends a POST, PUT, PATCH, or DELETE to a path of the Apify API, https://docs.apify.com/api/v2, as a proxy. */ export const apifyApiWrite: ToolEntry = Object.freeze({ type: TOOL_TYPE.INTERNAL, @@ -96,7 +108,9 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); - const resolved = parsed.method ? { method: parsed.method } : await inferMethod(parsed.path); + const resolved = parsed.method + ? { method: parsed.method } + : await inferMethod(parsed.path, toolArgs.loadedToolNames); if ('error' in resolved) return respondUserError(resolved.error); return callApi({ client: toolArgs.apifyClient, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index bab2f666b..66e4cf42b 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -623,6 +623,28 @@ describe('apify-api-write', () => { expect(result.structuredContent).toMatchObject({ method: 'DELETE', statusCode: 204, data: null }); }); + it('sends a PATCH', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); + + await callTool(apifyApiWrite, { path: 'datasets/abc', method: 'PATCH', body: { name: 'leads' } }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'PATCH' })); + }); + + it.each([ + [ + [HELPER_TOOLS.API_WRITE, HELPER_TOOLS.API_READ], + `The path has only the GET method, which this tool does not send; call it with ${HELPER_TOOLS.API_READ}.`, + ], + [[HELPER_TOOLS.API_WRITE], 'The path has only the GET method, which this tool does not send.'], + ])('never sends a GET for a path whose only method is GET (session %j)', async (loadedToolNames, message) => { + const result = await callTool(apifyApiWrite, { path: 'users/me' }, loadedToolNames); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(message); + expect(requestMock).not.toHaveBeenCalled(); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); From 4fd18386656992a6e221cce3cd194d15f5f95707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 29 Sep 2026 09:23:23 +0200 Subject: [PATCH 039/103] feat: Serve apify-api-search and apify-api-details without a token Both read only the public API spec from docs.apify.com and never call the API, so they join the tools served without an API token. The api category as a whole still needs a token, since apify-api-read and apify-api-write call the API. --- README.md | 2 +- src/tools/index.ts | 3 +++ tests/unit/utils.auth.test.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c1b376615..fb0563e2b 100644 --- a/README.md +++ b/README.md @@ -533,7 +533,7 @@ Upon launching, the Inspector will display a URL that you can open in your brows ## Unauthenticated access When the `tools` query parameter includes only tools explicitly enabled for unauthenticated use, the hosted server allows access without an API token. -Currently allowed tools: `search-actors`, `fetch-actor-details`, `search-apify-docs`, `fetch-apify-docs`. +Currently allowed tools: `search-actors`, `fetch-actor-details`, `search-apify-docs`, `fetch-apify-docs`, `apify-api-search`, `apify-api-details`. Example: `https://mcp.apify.com?tools=search-actors`. ## 🐦 Canary PR releases diff --git a/src/tools/index.ts b/src/tools/index.ts index 63c34e17c..be8498bfa 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,6 +18,9 @@ export const unauthEnabledTools: string[] = [ HELPER_TOOLS.DOCS_FETCH, HELPER_TOOLS.STORE_SEARCH, HELPER_TOOLS.ACTOR_GET_DETAILS, + // They read only the public API spec from docs.apify.com, never the API itself. + HELPER_TOOLS.API_SEARCH, + HELPER_TOOLS.API_DETAILS, ]; // Re-export from registry.ts diff --git a/tests/unit/utils.auth.test.ts b/tests/unit/utils.auth.test.ts index f5e37425e..67b3a53ea 100644 --- a/tests/unit/utils.auth.test.ts +++ b/tests/unit/utils.auth.test.ts @@ -36,6 +36,15 @@ describe('isApiTokenRequired', () => { ).toBe(false); }); + it('should NOT require token for the API search and details tools, which read only the public spec', () => { + expect(isApiTokenRequired({ toolCategoryKeys: ['apify-api-search', 'apify-api-details'] })).toBe(false); + }); + + it('should require token for the API call tools and the api category', () => { + expect(isApiTokenRequired({ toolCategoryKeys: ['apify-api-read'] })).toBe(true); + expect(isApiTokenRequired({ toolCategoryKeys: ['api'] })).toBe(true); + }); + it('should require token if any private tool is included', () => { expect( isApiTokenRequired({ From 3e6be78ac02627e0a9d3e9d321296ee88cf8e13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 2 Oct 2026 16:18:50 +0200 Subject: [PATCH 040/103] refactor: Reuse the API resource's request helpers in the API tools callApi now asserts its URL with the resource's isApifyApiUri instead of its own origin comparison, and it already detects the 256 KB abort with the resource's isMaxContentLengthAbort. The request itself stays a copy: the resource streams the body and the tool parses it, so sharing one request function needs a change to readApiResource, which is left for a separate refactor. --- src/resources/AGENTS.md | 4 +++- src/resources/api_resources.ts | 7 +++++-- src/tools/AGENTS.md | 3 +++ src/tools/api/apify_api_request.ts | 27 +++++++++++++++------------ 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/resources/AGENTS.md b/src/resources/AGENTS.md index 2642e4332..5ff23649a 100644 --- a/src/resources/AGENTS.md +++ b/src/resources/AGENTS.md @@ -26,7 +26,9 @@ scheme guidance (SHOULD only be used for client-fetchable URLs — ours are toke identity with the platform's own URLs is the feature. Revisit when tools start emitting `resource_link`s, where clients may fetch `https://` URIs directly. `isApifyApiUri()` gates reads to the configured API origin and rejects userinfo-bearing URLs (axios drops the `Authorization` -header for those, silently degrading to unauthenticated). +header for those, silently degrading to unauthenticated). The API tools' `callApi` +(`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()` and `isMaxContentLengthAbort()`, so +a change to either changes the tools too. `readApiResource()` streams the body verbatim — `httpClient.axios.request({ method: 'GET', responseType: 'stream', maxContentLength: MAX_INLINE_BYTES })` diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index 9550f63d9..ef6286133 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -60,7 +60,7 @@ function throwReadFailure(uri: string, status: number | undefined, message: stri * hand it Apify API URLs — never an arbitrary host. Userinfo-bearing URLs * (`user@api.apify.com`) are rejected even when the host is genuinely ours: axios drops * the default `Authorization` header for credentials-bearing URLs, so the read would - * silently run unauthenticated. + * silently run unauthenticated. The API tools' `callApi` asserts its request URL with it too. */ export function isApifyApiUri(uri: string): boolean { try { @@ -137,7 +137,10 @@ async function buildLinkOutResult(uri: string, apifyClient: ApifyClient): Promis ); } -/** The mid-consumption abort axios raises when a body crosses `maxContentLength`. */ +/** + * The mid-consumption abort axios raises when a body crosses `maxContentLength`. The API tools' + * `callApi` detects its oversized responses with it too. + */ export function isMaxContentLengthAbort(err: unknown): boolean { return isAxiosError(err) && err.code === 'ERR_BAD_RESPONSE' && err.message.includes('maxContentLength'); } diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 4a8d7d4c0..c7e96c92e 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -49,6 +49,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. looks up a legacy `acts` path as `actors`, the prefix the spec lists. It asserts that the URL stays on the API origin, sends one request with no retries, masks the session token in the response, keeps a query in the path out of the error it throws, and adds the closest spec paths to a 404. + The origin check (`isApifyApiUri`) and the detection of a body over `MAX_INLINE_BYTES` + (`isMaxContentLengthAbort`) are the API resource's own, imported from `../resources/api_resources.ts` + ([`../resources/AGENTS.md`](../resources/AGENTS.md#api-resources-api_resourcests)). The details and call tools log their arguments through `redactApiCallArgs` (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, `signature`, and `webhooks` query values redacted, and a query written into the path cut to `?[REDACTED]`. diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index c00eba7bf..7ca0746e6 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; -import { isMaxContentLengthAbort } from '../../resources/api_resources.js'; +import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; @@ -256,15 +256,18 @@ function toPlainRequestError(error: unknown): Error { * * The URL is the client's base URL and the normalized path, with a query string in the path kept * and the query parameters added after it. The path is not checked or encoded again: the agent - * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host. + * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host; + * the API resource's origin gate, `isApifyApiUri`, asserts it. * - * It goes through the client's axios instance, not `httpClient.call()`, like `readApiResource`: one - * attempt and no retries, since a retried write could apply twice, and apify-client would retry the - * `maxContentLength` abort as a network error. The instance still adds the token and the request-origin - * and payment headers, and parses JSON and text bodies. Like `readApiResource`, the request skips the - * setup `httpClient.call()` runs first, so it does not honor `HTTPS_PROXY` and goes out with axios's - * default User-Agent instead of apify-client's. A non-2xx response is thrown as the `ApifyApiError` - * apify-client itself builds, so it gets the usual tool error text and telemetry. + * It sends the request the way `readApiResource` does: through the client's axios instance, not + * `httpClient.call()`, with `maxContentLength` at `MAX_INLINE_BYTES`, and the resource's + * `isMaxContentLengthAbort` detects the abort of a larger body. One attempt and no retries, since a + * retried write could apply twice, and apify-client would retry the `maxContentLength` abort as a + * network error. Unlike the resource, it does not stream the body: the instance adds the token and + * the request-origin and payment headers, and parses JSON and text bodies. Like `readApiResource`, + * the request skips the setup `httpClient.call()` runs first, so it does not honor `HTTPS_PROXY` and + * goes out with axios's default User-Agent instead of apify-client's. A non-2xx response is thrown as + * the `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. */ export async function callApi(params: { client: ApifyClient; @@ -283,9 +286,9 @@ export async function callApi(params: { const path = formatApiPath(normalizedPath); // `client.baseUrl` already ends with /v2. const url = `${client.baseUrl}/${normalizedPath}`; - // An assertion: nothing after the base URL's `/v2/` can change the host that gets the token. - if (new URL(url).origin !== new URL(client.baseUrl).origin) - throw new Error(`The URL ${url} is not on the API host.`); + // An assertion with the resource's origin gate: nothing after the base URL's `/v2/` can change + // the host that gets the token. + if (!isApifyApiUri(url)) throw new Error(`The URL ${url} is not on the API host.`); let response: AxiosResponse; try { response = await client.httpClient.axios.request({ From 8b011acb110cc893c8831970e8be05e936dcdb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 2 Oct 2026 16:25:07 +0200 Subject: [PATCH 041/103] docs: Say that the API resource gets the same headers as the API tools The callApi JSDoc read as if the token and request-origin headers were a difference from the API resource. Both go through the same axios instance and get the same headers; the only difference is that the resource streams the body, so it gets no parsing. --- src/tools/api/apify_api_request.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 7ca0746e6..643c71dd1 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -263,11 +263,12 @@ function toPlainRequestError(error: unknown): Error { * `httpClient.call()`, with `maxContentLength` at `MAX_INLINE_BYTES`, and the resource's * `isMaxContentLengthAbort` detects the abort of a larger body. One attempt and no retries, since a * retried write could apply twice, and apify-client would retry the `maxContentLength` abort as a - * network error. Unlike the resource, it does not stream the body: the instance adds the token and - * the request-origin and payment headers, and parses JSON and text bodies. Like `readApiResource`, - * the request skips the setup `httpClient.call()` runs first, so it does not honor `HTTPS_PROXY` and - * goes out with axios's default User-Agent instead of apify-client's. A non-2xx response is thrown as - * the `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. + * network error. Unlike the resource, it does not stream the body, so the instance parses JSON and + * text bodies. Like the resource, the instance adds the token and the request-origin and payment + * headers. Like `readApiResource`, the request skips the setup `httpClient.call()` runs first, so it + * does not honor `HTTPS_PROXY` and goes out with axios's default User-Agent instead of apify-client's. + * A non-2xx response is thrown as the `ApifyApiError` apify-client itself builds, so it gets the usual + * tool error text and telemetry. */ export async function callApi(params: { client: ApifyClient; From 2026b2038173630bdc7f0538c3bd0c42e52f92e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 2 Oct 2026 16:26:44 +0200 Subject: [PATCH 042/103] refactor: Send the API tools' requests with the API resource's request function sendApifyApiRequest in api_resources.ts sends one request through the client's axios instance with maxContentLength at MAX_INLINE_BYTES. readApiResource and callApi both use it, so the origin check, the 256 KB cap, and the single attempt have one implementation. Its JSDoc gives the reason for skipping httpClient.call() once. Each caller keeps its own origin-gate error and abort handling, and the request config each sends is unchanged. --- src/resources/AGENTS.md | 25 +++++++-------- src/resources/api_resources.ts | 49 +++++++++++++++++++----------- src/tools/AGENTS.md | 5 +-- src/tools/api/apify_api_request.ts | 26 +++++----------- 4 files changed, 56 insertions(+), 49 deletions(-) diff --git a/src/resources/AGENTS.md b/src/resources/AGENTS.md index 5ff23649a..29692bf0c 100644 --- a/src/resources/AGENTS.md +++ b/src/resources/AGENTS.md @@ -27,23 +27,24 @@ identity with the platform's own URLs is the feature. Revisit when tools start e `resource_link`s, where clients may fetch `https://` URIs directly. `isApifyApiUri()` gates reads to the configured API origin and rejects userinfo-bearing URLs (axios drops the `Authorization` header for those, silently degrading to unauthenticated). The API tools' `callApi` -(`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()` and `isMaxContentLengthAbort()`, so -a change to either changes the tools too. - -`readApiResource()` streams the body verbatim — -`httpClient.axios.request({ method: 'GET', responseType: 'stream', maxContentLength: MAX_INLINE_BYTES })` -— and branches on the declared Content-Type: textual base types (text/*, JSON, XML) as `text` with -the full header, decoded with the declared charset (default utf-8; a charset Node cannot decode -falls through to blob — lossless beats mangled text, same rule as apify-client's body_parser); +(`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()`, `sendApifyApiRequest()`, and +`isMaxContentLengthAbort()`, so a change to any of them changes the tools too. + +`sendApifyApiRequest()` sends one request through `httpClient.axios.request` with +`maxContentLength: MAX_INLINE_BYTES`. `readApiResource()` sends +`{ method: 'GET', responseType: 'stream' }` through it, streams the body verbatim, and branches +on the declared Content-Type: textual base types (text/*, JSON, XML) as `text` with the full +header, decoded with the declared charset (default utf-8; a charset Node cannot decode falls +through to blob — lossless beats mangled text, same rule as apify-client's body_parser); everything else (including no Content-Type) as a base64 `blob` with the base MIME type; empty body as empty text preserving the Content-Type. The body is never parsed, so bytes round-trip exactly. axios enforces `MAX_INLINE_BYTES` (256 KB) mid-consumption on streamed responses (axios ≥1.16: byte-counting wrapper throws `ERR_BAD_RESPONSE`, counting decoded bytes) — after the request resolves, outside any retry wrapper. On trip, the proxy links out: a `text/plain` block carrying the store's signed `recordPublicUrl` for a KVS record, else the token-gated API URL, -plus a `limit`/`offset` paging hint. It calls the client's axios instance directly, not -`httpClient.call()`: with a stream body, `call()` hands non-2xx responses to `ApifyApiError` -unconsumed (junk message, stranded socket per retry) — one attempt, no retries; the error body is -read here to surface the API's message. +plus a `limit`/`offset` paging hint. `sendApifyApiRequest()` calls the client's axios instance +directly, not `httpClient.call()`: with a stream body, `call()` hands non-2xx responses to +`ApifyApiError` unconsumed (junk message, stranded socket per retry) — one attempt, no retries; the +error body is read here to surface the API's message. Genuine failures **throw** a domain error carrying `data: { uri }` (SEP-2164 / draft spec: the `server.ts` boundary turns it into a JSON-RPC error, never success-shaped content): 3xx/4xx except diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index ef6286133..9347025f9 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -3,6 +3,7 @@ import type { ReadResourceResult, TextResourceContents, } from '@modelcontextprotocol/sdk/types.js'; +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import { isAxiosError } from 'axios'; import type { ApifyClient } from '../apify_client.js'; @@ -145,6 +146,34 @@ export function isMaxContentLengthAbort(err: unknown): boolean { return isAxiosError(err) && err.code === 'ERR_BAD_RESPONSE' && err.message.includes('maxContentLength'); } +/** + * Sends one request to the Apify API with the body capped at `MAX_INLINE_BYTES`. `readApiResource` and + * the API tools' `callApi` both send their requests with it; each checks the URL with `isApifyApiUri` + * first and detects a larger body with `isMaxContentLengthAbort`. + * + * The request goes straight through `apifyClient.httpClient.axios`, the axios instance apify-client + * builds, so the token and the request-origin (and any payment) headers still apply, instead of + * `httpClient.call()`. One attempt, no retries: `call()` retries a failed request, so a retried write + * could apply twice, it retries the `maxContentLength` abort as a network error, and with + * `responseType: 'stream'` it hands a non-2xx response to `ApifyApiError` as an unconsumed stream, so + * the message degrades to junk and each retry strands an unread socket. The instance's + * `validateStatus: null` resolves a non-2xx response instead of throwing it, so callers check the + * status. Skipping `call()` also skips its setup (`ensureNodeInit()`), so the request does not honor + * `HTTPS_PROXY` and goes out with axios's default User-Agent instead of apify-client's. Fine for + * direct egress; a proxy-mandatory deployment needs an apify-client init hook. + * + * axios enforces `maxContentLength` itself, for a streamed body too (verified in axios@1.16.1: the + * adapter wraps a streamed response in a byte-counting generator that throws `ERR_BAD_RESPONSE` when + * the decoded size crosses the limit), so a larger body is aborted at ~`MAX_INLINE_BYTES`, never + * buffered whole. + */ +export async function sendApifyApiRequest( + apifyClient: ApifyClient, + config: Pick, +): Promise> { + return apifyClient.httpClient.axios.request({ ...config, maxContentLength: MAX_INLINE_BYTES }); +} + /** `charset` parameter of a Content-Type header, lowercased; `undefined` when absent. */ function parseCharset(contentType: string | undefined): string | undefined { const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? ''); @@ -185,14 +214,8 @@ function parseApiErrorMessage(body: Buffer | undefined): string | undefined { * resource (see SEP-2164). A body over `MAX_INLINE_BYTES` is NOT a failure — it is a * successful read returning a download pointer. * - * The request goes straight through `apifyClient.httpClient.axios` (the same axios instance - * apify-client builds internally, so token/origin headers still apply) instead of - * `httpClient.call()`: with `responseType: 'stream'`, a non-2xx reaches `call()` as an unconsumed - * stream — its `ApifyApiError` message degrades to junk and each retry attempt strands an unread - * socket. One attempt, no retries. `maxContentLength` is enforced by axios itself mid-consumption - * (verified in axios@1.16.1: the adapter wraps streamed responses in a byte-counting generator - * that throws `ERR_BAD_RESPONSE` when the decoded size crosses the limit), so an oversized body - * is aborted at ~`MAX_INLINE_BYTES`, never buffered whole. + * The request is one attempt through `sendApifyApiRequest`, which says why it skips + * `httpClient.call()`; axios aborts an oversized body mid-stream, so it is never buffered whole. */ export async function readApiResource(uri: string, apifyClient?: ApifyClient): Promise { if (!isApifyApiUri(uri)) { @@ -209,15 +232,7 @@ export async function readApiResource(uri: string, apifyClient?: ApifyClient): P let response: { data: unknown; headers: Record; status: number; statusText: string }; try { - // The raw axios instance skips `httpClient.call()` → `ensureNodeInit()`, so reads do NOT honor - // `HTTPS_PROXY` (auth + MCP-origin headers are instance defaults and still apply — not a leak). - // Fine for direct egress; a proxy-mandatory deployment needs an apify-client init hook. - response = await apifyClient.httpClient.axios.request({ - url: uri, - method: 'GET', - responseType: 'stream', - maxContentLength: MAX_INLINE_BYTES, - }); + response = await sendApifyApiRequest(apifyClient, { url: uri, method: 'GET', responseType: 'stream' }); } catch (err) { logHttpError(err, `resources/read request failed`, { uri }); throwReadFailure(uri, getHttpStatusCode(err), err instanceof Error ? err.message : String(err)); diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index c7e96c92e..bc989493f 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -49,8 +49,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. looks up a legacy `acts` path as `actors`, the prefix the spec lists. It asserts that the URL stays on the API origin, sends one request with no retries, masks the session token in the response, keeps a query in the path out of the error it throws, and adds the closest spec paths to a 404. - The origin check (`isApifyApiUri`) and the detection of a body over `MAX_INLINE_BYTES` - (`isMaxContentLengthAbort`) are the API resource's own, imported from `../resources/api_resources.ts` + The origin check (`isApifyApiUri`), the one capped request (`sendApifyApiRequest`), and the + detection of a body over `MAX_INLINE_BYTES` (`isMaxContentLengthAbort`) are the API resource's own, + imported from `../resources/api_resources.ts` ([`../resources/AGENTS.md`](../resources/AGENTS.md#api-resources-api_resourcests)). The details and call tools log their arguments through `redactApiCallArgs` (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, `signature`, and diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 643c71dd1..a4cb570af 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; -import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_resources.js'; +import { isApifyApiUri, isMaxContentLengthAbort, sendApifyApiRequest } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; @@ -259,16 +259,12 @@ function toPlainRequestError(error: unknown): Error { * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host; * the API resource's origin gate, `isApifyApiUri`, asserts it. * - * It sends the request the way `readApiResource` does: through the client's axios instance, not - * `httpClient.call()`, with `maxContentLength` at `MAX_INLINE_BYTES`, and the resource's - * `isMaxContentLengthAbort` detects the abort of a larger body. One attempt and no retries, since a - * retried write could apply twice, and apify-client would retry the `maxContentLength` abort as a - * network error. Unlike the resource, it does not stream the body, so the instance parses JSON and - * text bodies. Like the resource, the instance adds the token and the request-origin and payment - * headers. Like `readApiResource`, the request skips the setup `httpClient.call()` runs first, so it - * does not honor `HTTPS_PROXY` and goes out with axios's default User-Agent instead of apify-client's. - * A non-2xx response is thrown as the `ApifyApiError` apify-client itself builds, so it gets the usual - * tool error text and telemetry. + * It sends the request with the API resource's `sendApifyApiRequest`, which says why that is one + * attempt with no retries and the body capped at `MAX_INLINE_BYTES`, and the resource's + * `isMaxContentLengthAbort` detects the abort of a larger body. Unlike the resource, it does not + * stream the body, so the instance parses JSON and text bodies. Like the resource, the instance adds + * the token and the request-origin and payment headers. A non-2xx response is thrown as the + * `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. */ export async function callApi(params: { client: ApifyClient; @@ -292,13 +288,7 @@ export async function callApi(params: { if (!isApifyApiUri(url)) throw new Error(`The URL ${url} is not on the API host.`); let response: AxiosResponse; try { - response = await client.httpClient.axios.request({ - url, - method, - params: params.query, - maxContentLength: MAX_INLINE_BYTES, - signal: params.signal, - }); + response = await sendApifyApiRequest(client, { url, method, params: params.query, signal: params.signal }); } catch (error) { // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. if (params.signal?.aborted) return respondAborted(); From 5c1a53c8579968b69a3622c468d6ba51fa10be9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 2 Oct 2026 16:27:45 +0200 Subject: [PATCH 043/103] test: Cover the API tools' origin check and pin the API origin Add a test where the client's base URL is another host, or the API host with userinfo: the call fails with 'not on the API host' and sends no request. Pin APIFY_API_BASE_URL in the tool tests, so a different value in the shell no longer fails them. --- tests/unit/tools.apify_api.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 1a6530dce..39cef43c2 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -1,6 +1,6 @@ import { ApifyApiError } from 'apify-client'; import axios, { AxiosError, AxiosHeaders, CanceledError } from 'axios'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; @@ -61,10 +61,17 @@ async function callTool(tool: unknown, args: Record, loadedTool } beforeEach(() => { + // The tools check every URL against the configured API origin; pin it so the shell's + // APIFY_API_BASE_URL does not fail the tests. + vi.stubEnv('APIFY_API_BASE_URL', new URL(BASE_URL).origin); requestMock.mockReset(); vi.mocked(fetchApiOperationIndex).mockClear(); }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + /** The URL axios sends for the last request, with the query parameters added. */ function readSentUrl(): string { const [config] = requestMock.mock.lastCall as [{ url: string; params?: Record }]; @@ -317,6 +324,21 @@ describe('apify-api-read', () => { }, ); + it.each(['https://example.com/v2', 'https://user:pass@api.apify.com/v2'])( + 'sends nothing when the client base URL %s is not the configured API origin', + async (baseUrl) => { + const client = { + baseUrl, + httpClient: { axios: { request: requestMock } }, + } as unknown as InternalToolArgs['apifyClient']; + + await expect( + (apifyApiRead as HelperTool).call(stubToolCallContext({ path: 'datasets/abc' }, client)), + ).rejects.toThrow(`The URL ${baseUrl}/datasets/abc is not on the API host.`); + expect(requestMock).not.toHaveBeenCalled(); + }, + ); + it('sends a path the spec does not list, without loading the spec', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: { ok: true } })); From 330c8bca58a998752ff49060d57150ea744cc447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 2 Oct 2026 16:30:40 +0200 Subject: [PATCH 044/103] refactor: Send apify-api-write's JSON body through the API resource's request function --- src/resources/api_resources.ts | 8 ++++---- src/tools/api/apify_api_request.ts | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index 9347025f9..26a1528bd 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -147,9 +147,9 @@ export function isMaxContentLengthAbort(err: unknown): boolean { } /** - * Sends one request to the Apify API with the body capped at `MAX_INLINE_BYTES`. `readApiResource` and - * the API tools' `callApi` both send their requests with it; each checks the URL with `isApifyApiUri` - * first and detects a larger body with `isMaxContentLengthAbort`. + * Sends one request to the Apify API with the response body capped at `MAX_INLINE_BYTES`. + * `readApiResource` and the API tools' `callApi` both send their requests with it; each checks the URL + * with `isApifyApiUri` first and detects a larger body with `isMaxContentLengthAbort`. * * The request goes straight through `apifyClient.httpClient.axios`, the axios instance apify-client * builds, so the token and the request-origin (and any payment) headers still apply, instead of @@ -169,7 +169,7 @@ export function isMaxContentLengthAbort(err: unknown): boolean { */ export async function sendApifyApiRequest( apifyClient: ApifyClient, - config: Pick, + config: Pick, ): Promise> { return apifyClient.httpClient.axios.request({ ...config, maxContentLength: MAX_INLINE_BYTES }); } diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 1818c5f86..09ef0a03b 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; -import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_resources.js'; +import { isApifyApiUri, isMaxContentLengthAbort, sendApifyApiRequest } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; @@ -268,12 +268,13 @@ function toPlainRequestError(error: unknown): Error { * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host; * the API resource's origin gate, `isApifyApiUri`, asserts it. * - * It sends the request with the API resource's `sendApifyApiRequest`, which says why that is one - * attempt with no retries and the body capped at `MAX_INLINE_BYTES`, and the resource's - * `isMaxContentLengthAbort` detects the abort of a larger body. Unlike the resource, it does not - * stream the body, so the instance parses JSON and text bodies. Like the resource, the instance adds - * the token and the request-origin and payment headers. A non-2xx response is thrown as the - * `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. + * It sends the request, with a write's JSON body, through the API resource's `sendApifyApiRequest`, + * which says why that is one attempt with no retries and the response body capped at + * `MAX_INLINE_BYTES`, and the resource's `isMaxContentLengthAbort` detects the abort of a larger + * body. Unlike the resource, it does not stream the response, so the instance parses JSON and text + * bodies. Like the resource, the instance adds the token and the request-origin and payment headers. + * A non-2xx response is thrown as the `ApifyApiError` apify-client itself builds, so it gets the + * usual tool error text and telemetry. */ export async function callApi(params: { client: ApifyClient; @@ -299,7 +300,7 @@ export async function callApi(params: { if (!isApifyApiUri(url)) throw new Error(`The URL ${url} is not on the API host.`); let response: AxiosResponse; try { - response = await client.httpClient.axios.request({ + response = await sendApifyApiRequest(client, { url, method, params: params.query, @@ -309,7 +310,6 @@ export async function callApi(params: { data: JSON.stringify(params.body), headers: { 'Content-Type': 'application/json' }, }), - maxContentLength: MAX_INLINE_BYTES, signal: params.signal, }); } catch (error) { From 3e516d949e4db0bfd875d87439e34608f21ed4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 10:06:00 +0200 Subject: [PATCH 045/103] docs: Teach ~name for your own Actors and storages in the API tools --- src/tools/api/apify_api_details.ts | 4 ++-- src/tools/api/apify_api_request.ts | 7 ++++--- src/tools/api/apify_api_search.ts | 3 ++- tests/unit/tools.apify_api.test.ts | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 113ef47ac..3316626d7 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -78,8 +78,8 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; return respondUserError( - `The path ${path} is not in the API spec. A name is written username~name, as in ` + - `/v2/actors/john~my-actor.${next}`, + `The path ${path} is not in the API spec. A name is written username~name, or ~name for your ` + + `own Actor or storage, as in /v2/actors/~my-actor.${next}`, ); } const operations = parsed.method ? matched.filter((operation) => operation.method === parsed.method) : matched; diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index a4cb570af..607416a24 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -20,8 +20,8 @@ export const apiCallArgsShape = { .min(1) .describe( 'The API path with its values in it: actors, v2/actors, and /v2/actors are the same. A name is ' + - 'written username~name, as in /v2/actors/john~my-actor. A query string written into the path is ' + - 'sent too.', + 'written username~name, or ~name for your own Actor or storage, as in /v2/actors/~my-actor. ' + + 'A query string written into the path is sent too.', ), query: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -31,7 +31,8 @@ export const apiCallArgsShape = { /** The shared description lines of the read and write tools. */ export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. -Write the path as actors, v2/actors, or /v2/actors, with its values in it; a name is written username~name. +Write the path as actors, v2/actors, or /v2/actors, with its values in it. +A name is written username~name, or ~name for your own Actor or storage, as in /v2/actors/~my-actor. Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, so prefer asynchronous runs.`; diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index eb834b547..4b6745a50 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -35,7 +35,8 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { Search the Apify API reference for operations by keywords. Returns each operation's method, path template, summary, and link to the API reference.${nextStepsSentence} To call an operation, fill its path template with real values: /v2/datasets/{datasetId}/items - becomes /v2/datasets/abc/items, and a name is written username~name. + becomes /v2/datasets/abc/items. A name is written username~name, or ~name for your own Actor or + storage, as in /v2/actors/~my-actor. Prefer a dedicated Apify tool when one does what the user asks. USAGE: diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 39cef43c2..06402ea0b 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -244,8 +244,8 @@ describe('apify-api-details', () => { ]); const message = - 'The path /v2/acts/john/my-actor is not in the API spec. A name is written username~name, as in ' + - '/v2/actors/john~my-actor.'; + 'The path /v2/acts/john/my-actor is not in the API spec. A name is written username~name, or ~name ' + + 'for your own Actor or storage, as in /v2/actors/~my-actor.'; expectSoftFailInvalidInput(withSearch); expect(withSearch.content[0].text).toBe(`${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.`); expect(withoutSearch.content[0].text).toBe(message); From 8a7b83503f54a76d94f1a6c9dd013a40f01da1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 10:07:16 +0200 Subject: [PATCH 046/103] fix: Send a JSON string body as the value it holds in apify-api-write --- src/tools/api/apify_api_write.ts | 19 +++++++++++++++-- tests/unit/tools.apify_api.test.ts | 33 +++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index c2e4ff9cb..e7bd969f8 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -28,7 +28,7 @@ const apifyApiWriteArgs = z.object({ 'HTTP method: POST, PUT, PATCH, or DELETE. Omit it only when the path has one method in the API spec.', ), query: apiCallArgsShape.query, - body: z.unknown().optional().describe('The request body: any JSON value, usually an object, sent as JSON.'), + body: z.unknown().optional().describe('The request body: a JSON object or array, sent as JSON.'), }); function buildDescription({ hasTool }: ToolDescriptionContext): string { @@ -83,6 +83,19 @@ async function inferMethod( return { method }; } +/** + * The body to send. A client may send the untyped body as a string of JSON, so a string is parsed to + * the value it holds; sent as is, it would reach the API as a JSON string. + */ +function parseBody(body: unknown): { value: unknown } | { error: string } { + if (typeof body !== 'string') return { value: body }; + try { + return { value: JSON.parse(body) }; + } catch { + return { error: 'The body is a string that is not valid JSON; give it as a JSON object or array.' }; + } +} + /** * Sends a POST, PUT, PATCH, or DELETE to a path of the Apify API, https://docs.apify.com/api/v2, as a proxy. */ @@ -108,6 +121,8 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); + const body = parseBody(parsed.body); + if ('error' in body) return respondUserError(body.error); const resolved = parsed.method ? { method: parsed.method } : await inferMethod(parsed.path, toolArgs.loadedToolNames); @@ -118,7 +133,7 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ method: resolved.method, path: parsed.path, query: parsed.query, - body: parsed.body, + body: body.value, signal: toolArgs.signal, loadedToolNames: toolArgs.loadedToolNames, }); diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 1183dd794..62fefe6f3 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -724,7 +724,7 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it('sends the body as application/json through the real apify-client axios instance', async () => { + it('sends the body as application/json through the real apify-client axios instance, a string as the value it holds', async () => { const client = new ApifyClient({ token: 'test-token', baseUrl: 'https://api.apify.com' }); const sent: { url?: string; data?: unknown; contentType?: unknown }[] = []; client.httpClient.axios.defaults.adapter = async (config) => { @@ -746,19 +746,40 @@ describe('apify-api-write', () => { return (await (apifyApiWrite as HelperTool).call(context)) as TextToolResult; }; - const results = [await call({ name: 'x' }), await call(42), await call('text')]; + const results = [ + await call({ name: 'x' }), + await call(42), + await call('{"name":"x"}'), + await call(' [{"key":"a"}] '), + await call('"text"'), + ]; expect(results.map((result) => result.structuredContent)).toEqual( - Array(3).fill(expect.objectContaining({ statusCode: 201, data: { data: {} } })), + Array(5).fill(expect.objectContaining({ statusCode: 201, data: { data: {} } })), ); const url = 'https://api.apify.com/v2/key-value-stores/s/records/a%2FK'; expect(sent).toEqual([ { url, data: '{"name":"x"}', contentType: 'application/json' }, { url, data: '42', contentType: 'application/json' }, + { url, data: '{"name":"x"}', contentType: 'application/json' }, + { url, data: '[{"key":"a"}]', contentType: 'application/json' }, { url, data: '"text"', contentType: 'application/json' }, ]); }); + it.each(['text', '', '{"name":'])( + 'refuses the string body %j, which is not JSON, without a request', + async (body) => { + const result = await callTool(apifyApiWrite, { path: 'datasets/abc', method: 'PUT', body }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + 'The body is a string that is not valid JSON; give it as a JSON object or array.', + ); + expect(requestMock).not.toHaveBeenCalled(); + }, + ); + /** The abort axios throws for a body over `maxContentLength`, with the status Node's request keeps. */ function buildOversizeError(statusCode?: number) { return new AxiosError( @@ -829,6 +850,12 @@ describe('apify-api-write', () => { expect(args.body).toEqual({ value: 'secret' }); }); + it('redacts a body given as a JSON string', () => { + const args = { path: '/v2/datasets/abc', method: 'PUT', body: '{"value":"secret"}' }; + + expect(redactArgs?.(args)).toEqual({ path: '/v2/datasets/abc', method: 'PUT', body: '[REDACTED]' }); + }); + it('logs only the declared arguments, so a body under another key is left out', () => { const args = { path: '/v2/datasets/abc', requestBody: { value: 'secret' } }; From 720b3effbda4c26d3edc13556f9b404392993045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 10:16:34 +0200 Subject: [PATCH 047/103] docs: Teach ~name for any resource you own in the API tools --- src/tools/api/apify_api_details.ts | 4 ++-- src/tools/api/apify_api_request.ts | 4 ++-- src/tools/api/apify_api_search.ts | 4 ++-- tests/unit/tools.apify_api.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 3316626d7..b5edc33c2 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -78,8 +78,8 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; return respondUserError( - `The path ${path} is not in the API spec. A name is written username~name, or ~name for your ` + - `own Actor or storage, as in /v2/actors/~my-actor.${next}`, + `The path ${path} is not in the API spec. A name is written username~name, or ~name for one ` + + `you own, as in /v2/actors/~my-actor.${next}`, ); } const operations = parsed.method ? matched.filter((operation) => operation.method === parsed.method) : matched; diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 607416a24..7cd728f36 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -20,7 +20,7 @@ export const apiCallArgsShape = { .min(1) .describe( 'The API path with its values in it: actors, v2/actors, and /v2/actors are the same. A name is ' + - 'written username~name, or ~name for your own Actor or storage, as in /v2/actors/~my-actor. ' + + 'written username~name, or ~name for one you own, as in /v2/actors/~my-actor. ' + 'A query string written into the path is sent too.', ), query: z @@ -32,7 +32,7 @@ export const apiCallArgsShape = { /** The shared description lines of the read and write tools. */ export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. Write the path as actors, v2/actors, or /v2/actors, with its values in it. -A name is written username~name, or ~name for your own Actor or storage, as in /v2/actors/~my-actor. +A name is written username~name, or ~name for one you own, as in /v2/actors/~my-actor. Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, so prefer asynchronous runs.`; diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 4b6745a50..2f6b26cb5 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -35,8 +35,8 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { Search the Apify API reference for operations by keywords. Returns each operation's method, path template, summary, and link to the API reference.${nextStepsSentence} To call an operation, fill its path template with real values: /v2/datasets/{datasetId}/items - becomes /v2/datasets/abc/items. A name is written username~name, or ~name for your own Actor or - storage, as in /v2/actors/~my-actor. + becomes /v2/datasets/abc/items. A name is written username~name, or ~name for one you own, as in + /v2/actors/~my-actor. Prefer a dedicated Apify tool when one does what the user asks. USAGE: diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 06402ea0b..9b872d09d 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -245,7 +245,7 @@ describe('apify-api-details', () => { const message = 'The path /v2/acts/john/my-actor is not in the API spec. A name is written username~name, or ~name ' + - 'for your own Actor or storage, as in /v2/actors/~my-actor.'; + 'for one you own, as in /v2/actors/~my-actor.'; expectSoftFailInvalidInput(withSearch); expect(withSearch.content[0].text).toBe(`${message} Find the path with ${HELPER_TOOLS.API_SEARCH}.`); expect(withoutSearch.content[0].text).toBe(message); From 8aa2922547af9bd9bbe4be1a72de5b3b0cdf9178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 11:51:55 +0200 Subject: [PATCH 048/103] feat: Search API operations by words only their description has GET /v2/browser-info has the summary "Get browser info"; only its description says client IP address and request headers, so a search for those words found request queues instead. A keyword no operation name has now scores in the description, as a whole word, when the description has two such keywords. Question words and pronouns become stop words, since they are in no name but in most descriptions. --- src/tools/api/apify_api_spec.ts | 44 ++++++++++++++++++-- tests/unit/helpers/apify_api_spec_fixture.ts | 10 +++++ tests/unit/tools.apify_api_spec.test.ts | 26 +++++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 47333235c..95bf8868b 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -216,20 +216,41 @@ export async function fetchApiOperationIndex(): Promise = new Set([ 'a', 'all', 'an', 'and', + 'apify', + 'are', + 'be', + 'can', + 'did', + 'does', 'for', + 'has', + 'have', + 'how', 'in', + 'is', + 'its', 'my', 'of', 'on', 'or', + 'that', 'the', + 'this', 'to', + 'what', + 'where', + 'which', + 'who', + 'why', 'with', + 'you', + 'your', ]); /** Verbs for what each method does, so a verb the summary does not use, such as rename, still counts. */ @@ -246,6 +267,11 @@ function hasMatchingWord(term: string, words: string[]): boolean { return words.some((word) => word.startsWith(term) || term === `${word}s`); } +/** The same word, or its plural or singular: a prefix match finds too many words in prose, such as endpoint for end. */ +function hasWholeWord(term: string, words: string[]): boolean { + return words.some((word) => word === term || word === `${term}s` || term === `${word}s`); +} + /** * Paths that reach a run's own copy of a resource: the default storages and actions of a run or of the * last run, and the synchronous runs. A plain ask such as "add items to a dataset" means the storage @@ -258,28 +284,40 @@ const RUN_SCOPED_PENALTY = 2; /** * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the * operation ID or path, else 1 in the tags, else 2 when it is a verb for the operation's method. A verb - * alone does not match an operation. A run-scoped path loses 2 when no keyword is about runs or tasks. - * Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes before the same operation on a - * run's default dataset. + * alone does not match an operation. A keyword that no operation has in its summary, ID, path, or tags, + * such as ip, scores 2 as a whole word of the description instead, but only in a description with two or + * more such keywords, since one word alone is in too many descriptions. A run-scoped path loses 2 when no + * keyword is about runs or tasks. Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes before + * the same operation on a run's default dataset. */ export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { // One-letter terms, such as the s of "run's", match too many words. const terms = [...new Set(splitWords(query))].filter((term) => term.length > 1 && !SEARCH_STOP_WORDS.has(term)); const isAboutRuns = terms.some((term) => RUN_SCOPE_TERMS.has(term)); + const nameWords = [...index.values()].flatMap((operation) => + splitWords(`${operation.summary} ${operation.operationId} ${operation.path} ${operation.tags.join(' ')}`), + ); + // A keyword a name has is left to the names: a description also lists fields, such as the + // maxMonthlyUsageUsd of Update limits, which would rank that operation above Get limits. + const descriptionTerms = terms.filter((term) => !hasMatchingWord(term, nameWords)); const scored: { operation: ApiOperation; score: number }[] = []; for (const operation of index.values()) { const summaryWords = splitWords(operation.summary); const idWords = splitWords(`${operation.operationId} ${operation.path}`); const tagWords = splitWords(operation.tags.join(' ')); + const descriptionWords = splitWords(operation.description); const verbs = METHOD_VERBS[operation.method]; let score = 0; let verbScore = 0; + let descriptionHits = 0; for (const term of terms) { if (hasMatchingWord(term, summaryWords)) score += 3; else if (hasMatchingWord(term, idWords)) score += 2; else if (hasMatchingWord(term, tagWords)) score += 1; else if (verbs.includes(term)) verbScore += 2; + else if (descriptionTerms.includes(term) && hasWholeWord(term, descriptionWords)) descriptionHits += 1; } + if (descriptionHits >= 2) score += 2 * descriptionHits; if (score === 0) continue; const penalty = !isAboutRuns && RUN_SCOPED_PATH_REGEX.test(operation.path) ? RUN_SCOPED_PENALTY : 0; scored.push({ operation, score: score + verbScore - penalty }); diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index b5a756257..2971728e9 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -331,6 +331,16 @@ export const API_SPEC_FIXTURE = { parameters: [{ name: 'queueId', in: 'path', required: true, schema: { type: 'string' } }], }, }, + // A vague summary: only the description says what the operation is for. + '/v2/browser-info': { + get: { + operationId: 'tools_browser_info_get', + summary: 'Get browser info', + description: + 'Returns information about the HTTP request, including the client IP address, country code, request headers, and body length. This endpoint is designed for proxy testing.', + tags: ['Tools'], + }, + }, '/outside/v2': { get: { operationId: 'outside_get', summary: 'Outside the API' } }, '/v2/broken': { get: { summary: 'No operation ID' } }, }, diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index fd2f999b2..ac2e3610a 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -15,7 +15,7 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(37); + expect(index.size).toBe(38); }); it('indexes a PATCH operation, which the published spec does not have yet', () => { @@ -104,6 +104,30 @@ describe('searchApiOperations()', () => { expect(searchIds("store items in last run's dataset")[0]).toBe('actor_runs_last_dataset_items_post'); }); + it('finds an operation by keywords only its description has, and leaves a keyword a name has to the names', () => { + // No name has client, ip, address, or headers; the request queue summaries have request. + expect(searchIds('client ip address request headers')[0]).toBe('tools_browser_info_get'); + // The description of browser info says request too, but there it does not count. + expect(searchIds('request')).toEqual([ + 'requestQueue_requests_batch_post', + 'requestQueue_requests_batch_delete', + 'requestQueue_request_get', + 'requestQueue_request_put', + 'requestQueue_request_delete', + ]); + }); + + it('needs two description keywords, each a whole word', () => { + expect(searchIds('client')).toEqual([]); + // The description has endpoint, which starts with end. + expect(searchIds('ip end')).toEqual([]); + }); + + it('ignores question words, which no name has but most descriptions do', () => { + // The description of browser info has both is and this. + expect(searchIds('what is this')).toEqual([]); + }); + it('caps the results at the limit', () => { expect(searchApiOperations(index, 'dataset', 2)).toHaveLength(2); }); From 543053fad5001f88239c74ccf4de51cd5c2d2066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 12:33:06 +0200 Subject: [PATCH 049/103] fix: Address review of the API tools - apify-api-read is no longer annotated read-only or idempotent: a GET can start a run, such as a synchronous run of an Actor or task. - The closest spec paths are added only to a page-not-found 404 (a wrong path), not to a missing record, and other 404s do not load the spec. - formatOversizeMessage takes one input object. - The duplicate redaction test is merged into one. - apify-api-search has its own usage examples instead of apify-api-read's. - Doc comments are trimmed to what a reader needs. --- src/const.ts | 1 + src/resources/api_resources.ts | 33 +++--------- src/tools/AGENTS.md | 3 +- src/tools/api/apify_api_details.ts | 2 +- src/tools/api/apify_api_read.ts | 5 +- src/tools/api/apify_api_request.ts | 86 ++++++++++++++---------------- src/tools/api/apify_api_search.ts | 4 +- src/tools/api/apify_api_spec.ts | 11 ++-- src/types.ts | 7 ++- tests/unit/tools.apify_api.test.ts | 38 ++++++++----- 10 files changed, 88 insertions(+), 102 deletions(-) diff --git a/src/const.ts b/src/const.ts index 6d8c8dd51..623564756 100644 --- a/src/const.ts +++ b/src/const.ts @@ -302,6 +302,7 @@ export const APIFY_ERROR_TYPE_MEMORY_LIMIT_EXCEEDED = 'memory-limit-exceeded'; export const APIFY_ERROR_TYPE_CANNOT_START_ACTOR_RUNS = 'cannot-start-actor-runs'; export const APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK = 'cannot-publish-actor-task'; export const APIFY_ERROR_TYPE_INVALID_INPUT = 'invalid-input'; +export const APIFY_ERROR_TYPE_PAGE_NOT_FOUND = 'page-not-found'; // HTTP status codes export const HTTP_UNAUTHORIZED = 401; diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index 9347025f9..e266060ea 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -138,34 +138,18 @@ async function buildLinkOutResult(uri: string, apifyClient: ApifyClient): Promis ); } -/** - * The mid-consumption abort axios raises when a body crosses `maxContentLength`. The API tools' - * `callApi` detects its oversized responses with it too. - */ +/** The mid-consumption abort axios raises when a body crosses `maxContentLength`. */ export function isMaxContentLengthAbort(err: unknown): boolean { return isAxiosError(err) && err.code === 'ERR_BAD_RESPONSE' && err.message.includes('maxContentLength'); } /** - * Sends one request to the Apify API with the body capped at `MAX_INLINE_BYTES`. `readApiResource` and - * the API tools' `callApi` both send their requests with it; each checks the URL with `isApifyApiUri` - * first and detects a larger body with `isMaxContentLengthAbort`. - * - * The request goes straight through `apifyClient.httpClient.axios`, the axios instance apify-client - * builds, so the token and the request-origin (and any payment) headers still apply, instead of - * `httpClient.call()`. One attempt, no retries: `call()` retries a failed request, so a retried write - * could apply twice, it retries the `maxContentLength` abort as a network error, and with - * `responseType: 'stream'` it hands a non-2xx response to `ApifyApiError` as an unconsumed stream, so - * the message degrades to junk and each retry strands an unread socket. The instance's - * `validateStatus: null` resolves a non-2xx response instead of throwing it, so callers check the - * status. Skipping `call()` also skips its setup (`ensureNodeInit()`), so the request does not honor - * `HTTPS_PROXY` and goes out with axios's default User-Agent instead of apify-client's. Fine for - * direct egress; a proxy-mandatory deployment needs an apify-client init hook. - * - * axios enforces `maxContentLength` itself, for a streamed body too (verified in axios@1.16.1: the - * adapter wraps a streamed response in a byte-counting generator that throws `ERR_BAD_RESPONSE` when - * the decoded size crosses the limit), so a larger body is aborted at ~`MAX_INLINE_BYTES`, never - * buffered whole. + * Sends one request, with no retries, through apify-client's axios instance, which adds the token and the + * request-origin and payment headers. Check the URL with `isApifyApiUri` first and the status after: a + * non-2xx response resolves. It skips `httpClient.call()`, which retries (a write could apply twice, and a + * body over the size cap is downloaded again) and turns a streamed error body into a junk message; that + * also skips its `HTTPS_PROXY` support and User-Agent. axios aborts a body over `MAX_INLINE_BYTES` + * mid-stream (checked in axios 1.16.1), so it is never buffered whole. */ export async function sendApifyApiRequest( apifyClient: ApifyClient, @@ -214,8 +198,7 @@ function parseApiErrorMessage(body: Buffer | undefined): string | undefined { * resource (see SEP-2164). A body over `MAX_INLINE_BYTES` is NOT a failure — it is a * successful read returning a download pointer. * - * The request is one attempt through `sendApifyApiRequest`, which says why it skips - * `httpClient.call()`; axios aborts an oversized body mid-stream, so it is never buffered whole. + * It makes one attempt through `sendApifyApiRequest`, which says why it skips `httpClient.call()`. */ export async function readApiResource(uri: string, apifyClient?: ApifyClient): Promise { if (!isApifyApiUri(uri)) { diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index bc989493f..68fea62d6 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -48,7 +48,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. are the same), sends it as written with the query added after any query string in it, and looks up a legacy `acts` path as `actors`, the prefix the spec lists. It asserts that the URL stays on the API origin, sends one request with no retries, masks the session token in the response, - keeps a query in the path out of the error it throws, and adds the closest spec paths to a 404. + keeps a query in the path out of the error it throws, and adds the closest spec paths to a + `page-not-found` 404 (a wrong path), not to a missing record. The origin check (`isApifyApiUri`), the one capped request (`sendApifyApiRequest`), and the detection of a body over `MAX_INLINE_BYTES` (`isMaxContentLengthAbort`) are the API resource's own, imported from `../resources/api_resources.ts` diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index b5edc33c2..130694eb4 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -65,7 +65,7 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ idempotentHint: true, openWorldHint: false, }, - // Only path and method are declared; a query written into a pasted path, such as ?token=, is cut. + // A pasted path can hold a query such as ?token=. redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index a658579c4..9cf9ff068 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -46,9 +46,10 @@ export const apifyApiRead: ToolEntry = Object.freeze({ ajvValidate: compileSchema(z.toJSONSchema(apifyApiReadArgs)), annotations: { title: 'Read Apify API', - readOnlyHint: true, + // A GET can start a run, such as a synchronous run of an Actor or task. + readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 7cd728f36..e7dba0e49 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -5,7 +5,7 @@ import type { AxiosResponse } from 'axios'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; -import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; +import { APIFY_ERROR_TYPE_PAGE_NOT_FOUND, HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; import { isApifyApiUri, isMaxContentLengthAbort, sendApifyApiRequest } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; @@ -36,18 +36,16 @@ A name is written username~name, or ~name for one you own, as in /v2/actors/~my- Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, so prefer asynchronous runs.`; -/** Query parameters whose values grant access or carry secrets: a token, a storage signature, and webhooks with headers. */ +/** Query parameters whose values grant access or carry secrets; `webhooks` can hold webhook headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; /** Stands in for a logged or returned value that may carry a secret. */ const REDACTED = '[REDACTED]'; /** - * The logged copy of an API tool call's arguments (`redactArgs`). An allowlist: it keeps the path and - * method, the query with the values of secret parameters redacted, and only a marker for the body, - * which can carry environment variable values, webhook headers, or stored records. A query written - * into the path, such as a public URL's `?signature=`, is logged only as a marker. A path or query of - * the wrong type, which AJV refuses after the arguments are logged, is logged only as a marker. + * The logged copy of an API tool call's arguments (`redactArgs`), built as an allowlist. Secret query + * values, a query written into the path, the body, and a path or query of the wrong type are logged + * only as a marker. The body can carry environment variable values, webhook headers, or stored records. */ export function redactApiCallArgs({ path, method, query, body }: Record) { let loggedQuery: unknown; @@ -69,10 +67,7 @@ export function redactApiCallArgs({ path, method, query, body }: Record, normalizedPath: string): ApiOperation[] { const segments = toSpecPath(normalizedPath).split('/'); @@ -195,12 +189,17 @@ function findLogToolName(path: string, loadedToolNames: readonly string[]): stri * The message of a response over the inline limit. It names the parameters that narrow the response * when the path matches an operation of the spec that declares them. */ -async function formatOversizeMessage( - method: ApiMethod, - normalizedPath: string, - loadedToolNames: readonly string[], - signal?: AbortSignal, -): Promise { +async function formatOversizeMessage({ + method, + normalizedPath, + loadedToolNames, + signal, +}: { + method: ApiMethod; + normalizedPath: string; + loadedToolNames: readonly string[]; + signal?: AbortSignal; +}): Promise { const path = formatApiPath(normalizedPath); const message = `The response of ${method} ${path} is larger than ${MAX_INLINE_BYTES} bytes, so it is not returned.`; const index = await fetchApiOperationIndexIfAvailable(signal); @@ -215,7 +214,7 @@ async function formatOversizeMessage( return logToolName ? `${message} Get the end of the log with ${logToolName} instead.` : message; } -/** Adds the closest paths of the spec to a 404 error, when the spec is available. */ +/** Adds the spec's closest paths to the message of a page-not-found error, when the spec is available. */ async function addClosestPaths(error: ApifyApiError, normalizedPath: string, signal?: AbortSignal): Promise { const index = await fetchApiOperationIndexIfAvailable(signal); const paths = index ? findClosestApiPaths(index, normalizedPath) : []; @@ -224,9 +223,9 @@ async function addClosestPaths(error: ApifyApiError, normalizedPath: string, sig } /** - * A copy of a response body with the session's token replaced, for example in `GET /v2/browser-info`, - * which echoes the request headers. A binary body is masked too, since apify-client reads an error - * body that stays a Buffer into the error message. + * A copy of a response body with the session's token replaced, since a response can echo it, as + * `GET /v2/browser-info` echoes the request headers. A binary body is masked too: apify-client copies a + * Buffer error body into the message. */ function maskToken(data: unknown, token: string | undefined): unknown { if (!token || data === undefined) return data; @@ -242,9 +241,8 @@ function maskToken(data: unknown, token: string | undefined): unknown { } /** - * The request failure as a plain error with its message and code. The axios error keeps the request - * config, which holds the Authorization header and the request body, and the tool error log prints - * the whole error. + * The request failure as a plain error with only its message and code: the axios error holds the + * Authorization header and the request body, and the tool error log prints the whole error. */ function toPlainRequestError(error: unknown): Error { if (!(error instanceof Error)) return new Error(String(error)); @@ -254,18 +252,9 @@ function toPlainRequestError(error: unknown): Error { /** * Sends one request to an API path and returns the response body, like `apify api` in the Apify CLI. - * - * The URL is the client's base URL and the normalized path, with a query string in the path kept - * and the query parameters added after it. The path is not checked or encoded again: the agent - * encodes values. The URL starts with the base URL's `/v2/`, so no path can lead to another host; - * the API resource's origin gate, `isApifyApiUri`, asserts it. - * - * It sends the request with the API resource's `sendApifyApiRequest`, which says why that is one - * attempt with no retries and the body capped at `MAX_INLINE_BYTES`, and the resource's - * `isMaxContentLengthAbort` detects the abort of a larger body. Unlike the resource, it does not - * stream the body, so the instance parses JSON and text bodies. Like the resource, the instance adds - * the token and the request-origin and payment headers. A non-2xx response is thrown as the - * `ApifyApiError` apify-client itself builds, so it gets the usual tool error text and telemetry. + * The URL is the client's base URL and the normalized path, not encoded again, with `query` added. + * It sends with `sendApifyApiRequest` (one attempt, body capped at `MAX_INLINE_BYTES`) and throws a + * non-2xx response as apify-client's `ApifyApiError`, so it gets the usual tool error text and telemetry. */ export async function callApi(params: { client: ApifyClient; @@ -284,28 +273,31 @@ export async function callApi(params: { const path = formatApiPath(normalizedPath); // `client.baseUrl` already ends with /v2. const url = `${client.baseUrl}/${normalizedPath}`; - // An assertion with the resource's origin gate: nothing after the base URL's `/v2/` can change - // the host that gets the token. + // An assertion: nothing after the base URL can change the host that gets the token. if (!isApifyApiUri(url)) throw new Error(`The URL ${url} is not on the API host.`); let response: AxiosResponse; try { response = await sendApifyApiRequest(client, { url, method, params: params.query, signal: params.signal }); } catch (error) { - // A cancelled call is not a tool error; like the run and build tools, it gets the empty response. + // A cancelled call is not a tool error. if (params.signal?.aborted) return respondAborted(); if (isMaxContentLengthAbort(error)) { return respondUserError( - await formatOversizeMessage(method, normalizedPath, params.loadedToolNames, params.signal), + await formatOversizeMessage({ + method, + normalizedPath, + loadedToolNames: params.loadedToolNames, + signal: params.signal, + }), ); } throw toPlainRequestError(error); } const data = maskToken(response.data, params.token); if (response.status >= 300) { - // Without the query written into the path: apify-client puts the URL into the error's path and - // stack, and a 5xx error is logged, so a signature or token there would reach the log. + // Without the query in the path: apify-client puts the URL into the error, and a 5xx error is logged. const error = new ApifyApiError({ ...response, data, config: { ...response.config, url: stripQuery(url) } }, 1); - if (response.status === 404) await addClosestPaths(error, normalizedPath, params.signal); + if (error.type === APIFY_ERROR_TYPE_PAGE_NOT_FOUND) await addClosestPaths(error, normalizedPath, params.signal); throw error; } diff --git a/src/tools/api/apify_api_search.ts b/src/tools/api/apify_api_search.ts index 2f6b26cb5..b349db898 100644 --- a/src/tools/api/apify_api_search.ts +++ b/src/tools/api/apify_api_search.ts @@ -43,8 +43,8 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { - Use when no dedicated tool does what the user asks and the Apify API might. USAGE EXAMPLES: - - user_input: List the webhooks on my account - - user_input: How much of my monthly usage have I spent? + - user_input: Did my webhooks deliver their last calls? + - user_input: What is my monthly spending limit? `; } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 95bf8868b..fcdcc2280 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; import { apifyApiOperationsCache } from '../../state.js'; -/** The published Apify API spec, for search, details, and choosing a write's method. */ export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; /** HEAD is left out: it returns no body, and each HEAD operation has a GET twin. */ @@ -282,13 +281,9 @@ const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'ta const RUN_SCOPED_PENALTY = 2; /** - * Operations matching the query's keywords, best first. A keyword scores 3 in the summary, else 2 in the - * operation ID or path, else 1 in the tags, else 2 when it is a verb for the operation's method. A verb - * alone does not match an operation. A keyword that no operation has in its summary, ID, path, or tags, - * such as ip, scores 2 as a whole word of the description instead, but only in a description with two or - * more such keywords, since one word alone is in too many descriptions. A run-scoped path loses 2 when no - * keyword is about runs or tasks. Ties go to the shorter path, so `/v2/datasets/{datasetId}` comes before - * the same operation on a run's default dataset. + * Operations matching the query's keywords, best first, scored by where each keyword is found. A verb for + * the method alone does not match. The description counts only for keywords no operation's name has, and + * only with two or more of them, since one word alone is in too many descriptions. Ties go to the shorter path. */ export function searchApiOperations(index: Map, query: string, limit: number): ApiOperation[] { // One-letter terms, such as the s of "run's", match too many words. diff --git a/src/types.ts b/src/types.ts index 2ed038352..3f476511c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,10 +206,9 @@ export type HelperTool = ToolBase & { */ call: (toolArgs: InternalToolArgs) => Promise; /** - * Returns the copy of the call's arguments that is logged, for a tool whose arguments carry - * secrets. Applied in `prepareToolCallContext`, so every log of the arguments sees it. It runs - * before AJV validation: it must accept arguments of any shape and must not mutate them, and it - * still sees the undeclared keys AJV strips later, so build the logged copy as an allowlist. + * Returns the copy of the arguments that is logged, for a tool whose arguments carry secrets. It runs in + * `prepareToolCallContext`, before AJV validation, so it must accept any shape and not mutate the + * arguments; it also sees undeclared keys, so build the copy as an allowlist. */ redactArgs?: (args: Record) => Record; }; diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 9b872d09d..4ba2bbfe8 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -235,6 +235,12 @@ describe('apify-api-details', () => { method: 'GET', query: undefined, }); + for (const path of ['/v2/datasets/abc/items?signature=sig-secret', '/v2/datasets/abc?token=token-secret#x']) { + const logged = redactApiCallArgs({ path }); + + expect(logged.path).toBe(`${path.slice(0, path.indexOf('?'))}?[REDACTED]`); + expect(JSON.stringify(logged)).not.toContain('secret'); + } }); it('names the search tool on a path not in the spec only when the session has it', async () => { @@ -253,8 +259,12 @@ describe('apify-api-details', () => { }); describe('apify-api-read', () => { - it('is annotated as read-only', () => { - expect(apifyApiRead.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + it('is annotated as not read-only, since a GET can start a run', () => { + expect(apifyApiRead.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }); }); it('sends one GET to the path and returns the body as the API sends it', async () => { @@ -417,7 +427,7 @@ describe('apify-api-read', () => { expect((error as Error).stack).not.toContain('sig-secret'); }); - it('adds the closest paths of the spec to a 404', async () => { + it('adds the closest paths of the spec to a page-not-found 404', async () => { requestMock.mockResolvedValue( mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), ); @@ -432,6 +442,19 @@ describe('apify-api-read', () => { }); }); + it.each([ + ['record-not-found', 'actor-runs/abc', 'Actor run was not found.'], + ['record-or-token-not-found', 'actors/abc', 'Actor was not found.'], + ])('returns a %s 404 as the API sends it, without loading the spec', async (type, path, message) => { + requestMock.mockResolvedValue(mockResponse(404, { error: { type, message } })); + + const call = callTool(apifyApiRead, { path }); + + await expect(call).rejects.toBeInstanceOf(ApifyApiError); + await expect(call).rejects.toMatchObject({ statusCode: 404, type, message }); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + it('returns the plain 404 when the spec cannot be loaded', async () => { vi.mocked(fetchApiOperationIndex).mockRejectedValueOnce(new Error('Failed to load the Apify API operations')); requestMock.mockResolvedValue( @@ -553,15 +576,6 @@ describe('apify-api-read', () => { expect(args.query.signature).toBe('sig-secret'); }); - it('logs a query written into the path only as redacted', () => { - for (const path of ['/v2/datasets/abc/items?signature=sig-secret', '/v2/datasets/abc?token=token-secret#x']) { - const logged = redactApiCallArgs({ path }); - - expect(logged.path).toBe(`${path.slice(0, path.indexOf('?'))}?[REDACTED]`); - expect(JSON.stringify(logged)).not.toContain('secret'); - } - }); - it('logs only the declared arguments and a body only as redacted', () => { const logged = redactApiCallArgs({ path: '/v2/datasets/abc', From 0bcbf07c026fbbcf5f08abbe481d29eae1324e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 6 Oct 2026 13:58:00 +0200 Subject: [PATCH 050/103] fix: Address the second review of the API tools - apify-api-read's description says a GET can start a paid Actor run, as the synchronous run endpoints do, and names call-actor when the session has it. No path is refused. - Its example call reads webhook dispatches instead of dataset items, which get-dataset-items already reads. - The server instructions send API GETs to apify-api-read when the session has it; resources/read stays for binary records. - sendApifyApiRequest moves to apify_client.ts: it is a general API function, not an MCP resource one. - The API spec index is cached for a day instead of an hour. --- src/apify_client.ts | 17 +++++++++++++++++ src/resources/AGENTS.md | 8 ++++---- src/resources/api_resources.ts | 21 +++------------------ src/state.ts | 6 +++++- src/tools/AGENTS.md | 8 ++++---- src/tools/api/apify_api_read.ts | 4 +++- src/tools/api/apify_api_request.ts | 3 ++- src/tools/api/apify_api_spec.ts | 2 +- src/utils/server-instructions/index.ts | 6 +++++- tests/unit/server-instructions.test.ts | 24 ++++++++++++++++++++++++ tests/unit/tools.apify_api.test.ts | 13 +++++++++++++ 11 files changed, 81 insertions(+), 31 deletions(-) diff --git a/src/apify_client.ts b/src/apify_client.ts index 4e35c1c26..446e163ee 100644 --- a/src/apify_client.ts +++ b/src/apify_client.ts @@ -1,6 +1,8 @@ import type { ApifyClientOptions } from 'apify-client'; import { ApifyClient as _ApifyClient } from 'apify-client'; +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { MAX_INLINE_BYTES } from './const.js'; import type { PaymentHeaders } from './payments/types.js'; // Appended to the client's User-Agent via apify-client's userAgentSuffix option. @@ -60,3 +62,18 @@ export class ApifyClient extends _ApifyClient { }); } } + +/** + * Sends one request, with no retries, through apify-client's axios instance, which adds the token and the + * request-origin and payment headers. Check the URL with `isApifyApiUri` (`resources/api_resources.ts`) + * first and the status after: a non-2xx response resolves. It skips `httpClient.call()`, which retries (a + * write could apply twice, and a body over the size cap is downloaded again) and turns a streamed error + * body into a junk message; that also skips its `HTTPS_PROXY` support and User-Agent. axios aborts a body + * over `MAX_INLINE_BYTES` mid-stream (checked in axios 1.16.1), so it is never buffered whole. + */ +export async function sendApifyApiRequest( + apifyClient: ApifyClient, + config: Pick, +): Promise> { + return apifyClient.httpClient.axios.request({ ...config, maxContentLength: MAX_INLINE_BYTES }); +} diff --git a/src/resources/AGENTS.md b/src/resources/AGENTS.md index 29692bf0c..de78febc2 100644 --- a/src/resources/AGENTS.md +++ b/src/resources/AGENTS.md @@ -27,11 +27,11 @@ identity with the platform's own URLs is the feature. Revisit when tools start e `resource_link`s, where clients may fetch `https://` URIs directly. `isApifyApiUri()` gates reads to the configured API origin and rejects userinfo-bearing URLs (axios drops the `Authorization` header for those, silently degrading to unauthenticated). The API tools' `callApi` -(`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()`, `sendApifyApiRequest()`, and -`isMaxContentLengthAbort()`, so a change to any of them changes the tools too. +(`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()` and `isMaxContentLengthAbort()`, so +a change to either changes the tools too. -`sendApifyApiRequest()` sends one request through `httpClient.axios.request` with -`maxContentLength: MAX_INLINE_BYTES`. `readApiResource()` sends +`sendApifyApiRequest()` (`../apify_client.ts`, shared with `callApi`) sends one request through +`httpClient.axios.request` with `maxContentLength: MAX_INLINE_BYTES`. `readApiResource()` sends `{ method: 'GET', responseType: 'stream' }` through it, streams the body verbatim, and branches on the declared Content-Type: textual base types (text/*, JSON, XML) as `text` with the full header, decoded with the declared charset (default utf-8; a charset Node cannot decode falls diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index e266060ea..d8cd8b531 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -3,11 +3,10 @@ import type { ReadResourceResult, TextResourceContents, } from '@modelcontextprotocol/sdk/types.js'; -import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import { isAxiosError } from 'axios'; import type { ApifyClient } from '../apify_client.js'; -import { getApifyAPIBaseUrl } from '../apify_client.js'; +import { getApifyAPIBaseUrl, sendApifyApiRequest } from '../apify_client.js'; import { MAX_INLINE_BYTES } from '../const.js'; import { InternalError, InvalidParamsError } from '../mcp/errors.js'; import { parseBaseMimeType } from '../tools/storage/storage_helpers.js'; @@ -143,21 +142,6 @@ export function isMaxContentLengthAbort(err: unknown): boolean { return isAxiosError(err) && err.code === 'ERR_BAD_RESPONSE' && err.message.includes('maxContentLength'); } -/** - * Sends one request, with no retries, through apify-client's axios instance, which adds the token and the - * request-origin and payment headers. Check the URL with `isApifyApiUri` first and the status after: a - * non-2xx response resolves. It skips `httpClient.call()`, which retries (a write could apply twice, and a - * body over the size cap is downloaded again) and turns a streamed error body into a junk message; that - * also skips its `HTTPS_PROXY` support and User-Agent. axios aborts a body over `MAX_INLINE_BYTES` - * mid-stream (checked in axios 1.16.1), so it is never buffered whole. - */ -export async function sendApifyApiRequest( - apifyClient: ApifyClient, - config: Pick, -): Promise> { - return apifyClient.httpClient.axios.request({ ...config, maxContentLength: MAX_INLINE_BYTES }); -} - /** `charset` parameter of a Content-Type header, lowercased; `undefined` when absent. */ function parseCharset(contentType: string | undefined): string | undefined { const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? ''); @@ -198,7 +182,8 @@ function parseApiErrorMessage(body: Buffer | undefined): string | undefined { * resource (see SEP-2164). A body over `MAX_INLINE_BYTES` is NOT a failure — it is a * successful read returning a download pointer. * - * It makes one attempt through `sendApifyApiRequest`, which says why it skips `httpClient.call()`. + * It makes one attempt through `sendApifyApiRequest` (`../apify_client.ts`), which says why it skips + * `httpClient.call()`. */ export async function readApiResource(uri: string, apifyClient?: ApifyClient): Promise { if (!isApifyApiUri(uri)) { diff --git a/src/state.ts b/src/state.ts index 0c441bf9d..783f9ddb7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -6,6 +6,7 @@ const ACTOR_CACHE_MAX_SIZE = 500; const ACTOR_CACHE_TTL_SECS = 30 * 60; // 30 minutes const APIFY_DOCS_CACHE_MAX_SIZE = 500; const APIFY_DOCS_CACHE_TTL_SECS = 60 * 60; // 1 hour +const APIFY_API_OPERATIONS_CACHE_TTL_SECS = 24 * 60 * 60; // 1 day export const actorDefinitionCache = new TTLLRUCache( ACTOR_CACHE_MAX_SIZE, @@ -18,4 +19,7 @@ export const searchApifyDocsCache = new TTLLRUCache( /** Stores processed Markdown content */ export const fetchApifyDocsCache = new TTLLRUCache(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS); /** The Apify API operation index built from the published OpenAPI spec; one entry, keyed by the spec URL. */ -export const apifyApiOperationsCache = new TTLLRUCache>(1, APIFY_DOCS_CACHE_TTL_SECS); +export const apifyApiOperationsCache = new TTLLRUCache>( + 1, + APIFY_API_OPERATIONS_CACHE_TTL_SECS, +); diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 68fea62d6..abbb783b8 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -42,7 +42,7 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the operations on a path, and send a GET to a path. The call tool is a proxy to the API, like `apify api` in the Apify CLI: it refuses nothing the API accepts. `apify_api_spec.ts` builds the - operation index from the spec (cached for an hour); search and details use it, and a call uses it + operation index from the spec (cached for a day); search and details use it, and a call uses it only for hints, waiting at most a few seconds, so a failed download does not stop a call. `apify_api_request.ts` normalizes the path as the CLI does (`actors`, `v2/actors`, and `/v2/actors` are the same), sends it as written with the query added after any query string in it, and @@ -50,9 +50,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. on the API origin, sends one request with no retries, masks the session token in the response, keeps a query in the path out of the error it throws, and adds the closest spec paths to a `page-not-found` 404 (a wrong path), not to a missing record. - The origin check (`isApifyApiUri`), the one capped request (`sendApifyApiRequest`), and the - detection of a body over `MAX_INLINE_BYTES` (`isMaxContentLengthAbort`) are the API resource's own, - imported from `../resources/api_resources.ts` + The one capped request (`sendApifyApiRequest`) is in `../apify_client.ts`. The origin check + (`isApifyApiUri`) and the detection of a body over `MAX_INLINE_BYTES` (`isMaxContentLengthAbort`) + are the API resource's own, imported from `../resources/api_resources.ts` ([`../resources/AGENTS.md`](../resources/AGENTS.md#api-resources-api_resourcests)). The details and call tools log their arguments through `redactApiCallArgs` (`redactArgs`): an allowlist of path, method, query, and body, with the body and the `token`, `signature`, and diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 9cf9ff068..ddb6cc01e 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -15,13 +15,15 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { const getParameters = hasTool(HELPER_TOOLS.API_DETAILS) ? `\nGet the operation's query parameters first with ${HELPER_TOOLS.API_DETAILS} and method GET.` : ''; + const runActor = hasTool(HELPER_TOOLS.ACTOR_CALL) ? ` Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.` : ''; return dedent` Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc. + A GET can start a paid Actor run, as the synchronous run endpoints do.${runActor} ${API_CALL_DESCRIPTION}${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. - Example call: {"path": "/v2/datasets/abc/items", "query": {"format": "json", "limit": 10}} + Example call: {"path": "/v2/webhook-dispatches", "query": {"limit": 10, "desc": true}} USAGE: - Use for data no dedicated tool returns, such as webhooks, usage, and limits. diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index e7dba0e49..186bc7a5c 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -5,8 +5,9 @@ import type { AxiosResponse } from 'axios'; import { z } from 'zod'; import type { ApifyClient } from '../../apify_client.js'; +import { sendApifyApiRequest } from '../../apify_client.js'; import { APIFY_ERROR_TYPE_PAGE_NOT_FOUND, HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; -import { isApifyApiUri, isMaxContentLengthAbort, sendApifyApiRequest } from '../../resources/api_resources.js'; +import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index fcdcc2280..5348debd1 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -198,7 +198,7 @@ async function downloadApiOperationIndex(): Promise> { } /** - * The operation index, from the published spec, cached for an hour. Concurrent calls share one + * The operation index, from the published spec, cached for a day. Concurrent calls share one * download. A failed download throws and is not cached, and the next call tries again. */ export async function fetchApiOperationIndex(): Promise> { diff --git a/src/utils/server-instructions/index.ts b/src/utils/server-instructions/index.ts index 598cd852f..87d73f79c 100644 --- a/src/utils/server-instructions/index.ts +++ b/src/utils/server-instructions/index.ts @@ -81,6 +81,10 @@ export function getServerInstructions( ? buildWidgetWorkflowSection({ hasCall, hasRunsGet, hasCallWidget, hasRunsGetWidget }) : ''; + const apiReadLine = hasTool(HELPER_TOOLS.API_READ) + ? `- Use \`${HELPER_TOOLS.API_READ}\` for API GETs. If your client can read resources, \`resources/read\` also returns binary records, such as key-value store files, which \`${HELPER_TOOLS.API_READ}\` does not.\n` + : ''; + const toolDependencies = hasCall ? `### Tool dependencies - \`${HELPER_TOOLS.ACTOR_CALL}\`: @@ -225,7 +229,7 @@ These tools are called **Actors**. They enable you to extract structured data fr - Actor and tool results return storage IDs, not resource URLs — build the URL from the ID (e.g. a \`datasetId\` becomes \`${apiBaseUrl}/v2/datasets/{datasetId}/items\`) and read it via \`resources/read\`. - Reads inline up to ~256 KB; a larger response is not downloaded — it returns a short notice with a download URL instead of the body, so page large datasets/lists with \`limit\` and \`offset\` to stay under the cap. - Examples: \`${apiBaseUrl}/v2/datasets/{datasetId}/items?clean=true&format=json&limit=100\`, \`${apiBaseUrl}/v2/key-value-stores/{storeId}/records/{recordKey}\`. \`resources/templates/list\` enumerates the common shapes with their paging parameters. -${widgetWorkflowSection}${dependenciesAndDisambiguation}${ +${apiReadLine}${widgetWorkflowSection}${dependenciesAndDisambiguation}${ hasTool(HELPER_TOOLS.PROBLEM_REPORT) ? ` If a tool or Actor fails and you cannot resolve it, you can report it with \`${HELPER_TOOLS.PROBLEM_REPORT}\`. diff --git a/tests/unit/server-instructions.test.ts b/tests/unit/server-instructions.test.ts index 134ce9c74..c2e6227de 100644 --- a/tests/unit/server-instructions.test.ts +++ b/tests/unit/server-instructions.test.ts @@ -226,6 +226,30 @@ describe('getServerInstructions()', () => { }); }); + describe('"## Apify API resources" section', () => { + const API_READ_LINE = + `- Use \`${HELPER_TOOLS.API_READ}\` for API GETs. If your client can read resources, \`resources/read\` ` + + `also returns binary records, such as key-value store files, which \`${HELPER_TOOLS.API_READ}\` does not.\n`; + + it('sends API GETs to apify-api-read when the session has it', () => { + const instructions = getServerInstructions(SERVER_MODE.DEFAULT, only(HELPER_TOOLS.API_READ)); + const start = instructions.indexOf('## Apify API resources'); + const end = instructions.indexOf('\n## ', start + 1); + expect(instructions.slice(start, end === -1 ? undefined : end)).toContain(API_READ_LINE); + }); + + it('adds only that line, and nothing when the session lacks apify-api-read', () => { + const withRead = getServerInstructions(SERVER_MODE.DEFAULT, only(HELPER_TOOLS.API_READ)); + const withoutRead = getServerInstructions( + SERVER_MODE.DEFAULT, + only(HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_DETAILS), + ); + expect(withoutRead).not.toContain(HELPER_TOOLS.API_READ); + expect(withRead).not.toBe(withoutRead); + expect(withRead.replace(API_READ_LINE, '')).toBe(withoutRead); + }); + }); + describe('"Tool dependencies and disambiguation" section', () => { const HEADING = '## Tool dependencies and disambiguation'; diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 4ba2bbfe8..0640a3a59 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -20,10 +20,12 @@ import { apifyApiSearchOutputSchema, } from '../../src/tools/structured_output_schemas.js'; import type { HelperTool, InternalToolArgs } from '../../src/types.js'; +import { ALL_TOOLS_PRESENT } from '../../src/types.js'; import { API_SPEC_FIXTURE } from './helpers/apify_api_spec_fixture.js'; import { expectSchemaConformingStructuredContent, expectSoftFailInvalidInput, + only, stubToolCallContext, type TextToolResult, type ToolTelemetrySnapshot, @@ -267,6 +269,17 @@ describe('apify-api-read', () => { }); }); + it('says a GET can start a paid run and names call-actor only when the session has it', () => { + const withCallActor = apifyApiRead.buildDescription!(ALL_TOOLS_PRESENT); + const withoutCallActor = apifyApiRead.buildDescription!(only(HELPER_TOOLS.API_READ)); + const paidRun = 'A GET can start a paid Actor run, as the synchronous run endpoints do.'; + + expect(apifyApiRead.description).toBe(withCallActor); + expect(withCallActor).toContain(`${paidRun} Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); + expect(withoutCallActor).toContain(paidRun); + expect(withoutCallActor).not.toContain(HELPER_TOOLS.ACTOR_CALL); + }); + it('sends one GET to the path and returns the body as the API sends it', async () => { const body = { data: { total: 1, items: [{ id: 'abc' }] } }; requestMock.mockResolvedValue(mockResponse(200, body)); From 57d8a094b3bfe3d2a5563d5b367edcaeca9b1ead Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:22:13 +0000 Subject: [PATCH 051/103] fix: Mark apify-api-read as open-world A GET can start an Actor run, as the synchronous run endpoints do, so the read tool reaches outside the Apify platform like call-actor, build-actor, and apify-api-write, which all set openWorldHint: true. Claude-Session: https://claude.ai/code/session_01XB8T1bX89q7Nwz67GYP3Km --- src/tools/api/apify_api_read.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index ddb6cc01e..c71699b45 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -52,7 +52,7 @@ export const apifyApiRead: ToolEntry = Object.freeze({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, - openWorldHint: false, + openWorldHint: true, }, // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. redactArgs: redactApiCallArgs, From 32c39bc3b42d56e16a66c8ae77f9b7e74067c129 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:27:09 +0000 Subject: [PATCH 052/103] fix: Keep the API's punctuation before the closest paths on a 404 The API's page-not-found message ends with a question mark, so the hint read "Did you specify it correctly?. The closest paths". A period is now added only when the message ends without one of . ! ?. Claude-Session: https://claude.ai/code/session_01XB8T1bX89q7Nwz67GYP3Km --- src/tools/api/apify_api_request.ts | 2 +- tests/unit/tools.apify_api.test.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 186bc7a5c..e856c218e 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -220,7 +220,7 @@ async function addClosestPaths(error: ApifyApiError, normalizedPath: string, sig const index = await fetchApiOperationIndexIfAvailable(signal); const paths = index ? findClosestApiPaths(index, normalizedPath) : []; if (paths.length === 0) return; - error.message = `${error.message.replace(/\.$/, '')}. The closest paths in the API spec: ${paths.join(', ')}`; + error.message = `${error.message.replace(/([^.!?])$/, '$1.')} The closest paths in the API spec: ${paths.join(', ')}`; } /** diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 0640a3a59..bfcde5db8 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -440,10 +440,15 @@ describe('apify-api-read', () => { expect((error as Error).stack).not.toContain('sig-secret'); }); - it('adds the closest paths of the spec to a page-not-found 404', async () => { - requestMock.mockResolvedValue( - mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), - ); + it.each([ + ['Page not found.', 'Page not found.'], + [ + 'We have bad news: there is no API endpoint at this URL. Did you specify it correctly?', + 'We have bad news: there is no API endpoint at this URL. Did you specify it correctly?', + ], + ['Page not found', 'Page not found.'], + ])('adds the closest paths of the spec to a page-not-found 404: %s', async (apiMessage, sentence) => { + requestMock.mockResolvedValue(mockResponse(404, { error: { type: 'page-not-found', message: apiMessage } })); const call = callTool(apifyApiRead, { path: 'datasets/abc/itemz' }); @@ -451,7 +456,7 @@ describe('apify-api-read', () => { await expect(call).rejects.toMatchObject({ statusCode: 404, type: 'page-not-found', - message: `Page not found. The closest paths in the API spec: ${findClosestApiPaths(INDEX, 'datasets/abc/itemz').join(', ')}`, + message: `${sentence} The closest paths in the API spec: ${findClosestApiPaths(INDEX, 'datasets/abc/itemz').join(', ')}`, }); }); From 4b7ef878637799c865118e22fe3d1069ee6442f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 10:02:02 +0200 Subject: [PATCH 053/103] feat: Serve apify-api-write only when a session names it apify-api-write moves from the api category to the tools in no category, next to get-actor-list, as delete-actor does. tools=api serves search, details, and read; tools=api,apify-api-write adds the write tool. It changes or deletes data at any API path, so a session gets it only by asking for it. --- README.md | 2 +- src/tools/AGENTS.md | 6 ++-- src/tools/registry.ts | 6 ++-- tests/unit/tools.mode_contract.test.ts | 7 +--- tests/unit/utils.tools_loader.test.ts | 45 ++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4ce00c1fa..4ebaa2c0f 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ Legend for the **Enabled by default** column: | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | | `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. | | -| `apify-api-write` | api | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. | | +| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 75bf6a1c7..69c69ac20 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -40,8 +40,10 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. revision rules, and the text-or-base64 detection. Versions not stored as files (Git repository, gist, or zip) are refused. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the - operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The call tools are - proxies to the API, like `apify api` in the Apify CLI: they refuse nothing the API accepts. + operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The write tool is in + no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write + tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI: + they refuse nothing the API accepts. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/registry.ts b/src/tools/registry.ts index d7db8b1a0..10ddd423c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -78,7 +78,7 @@ export const toolCategories = { schedules: [createSchedule, getSchedule, updateSchedule, deleteSchedule], builds: [getActorBuild, getActorBuildLog, getActorBuildList, buildActor], source: [getActorVersion], - api: [apifyApiSearch, apifyApiDetails, apifyApiRead, apifyApiWrite], + api: [apifyApiSearch, apifyApiDetails, apifyApiRead], dev: [reportProblem], } satisfies Record; @@ -111,8 +111,10 @@ export const ALL_WIDGET_TOOLS: readonly ToolEntry[] = [ /** * Non-widget tools in no category: never served by default or by a category, only when named in * `tools=`, in every mode. Temporary: `get-actor-list` returns to a category once evals cover it. + * `apify-api-write` stays here: it changes or deletes data at any API path, so a session gets it only + * by asking for it, as in `tools=api,apify-api-write`. */ -export const UNCATEGORIZED_TOOLS: readonly ToolEntry[] = [getActorList]; +export const UNCATEGORIZED_TOOLS: readonly ToolEntry[] = [getActorList, apifyApiWrite]; /** * Apps-mode auto-pairing: a widget is added iff its base tool is present — see diff --git a/tests/unit/tools.mode_contract.test.ts b/tests/unit/tools.mode_contract.test.ts index 9d216ad7a..24c362f89 100644 --- a/tests/unit/tools.mode_contract.test.ts +++ b/tests/unit/tools.mode_contract.test.ts @@ -136,12 +136,7 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => { }); it('has correct tools in api category (both modes)', () => { - const expected = [ - HELPER_TOOLS.API_SEARCH, - HELPER_TOOLS.API_DETAILS, - HELPER_TOOLS.API_READ, - HELPER_TOOLS.API_WRITE, - ]; + const expected = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_DETAILS, HELPER_TOOLS.API_READ]; expect(toolNames(defaultCategories.api)).toEqual(expected); expect(toolNames(appsCategories.api)).toEqual(expected); }); diff --git a/tests/unit/utils.tools_loader.test.ts b/tests/unit/utils.tools_loader.test.ts index 1ee4ab944..0e1c33e9e 100644 --- a/tests/unit/utils.tools_loader.test.ts +++ b/tests/unit/utils.tools_loader.test.ts @@ -117,6 +117,12 @@ describe('toolNamesToInput', () => { tools: [HELPER_TOOLS.ACTOR_LIST_GET], }); }); + + it('classifies apify-api-write (in no category) as an internal tool, not an actor ID', () => { + expect(toolNamesToInput([HELPER_TOOLS.API_WRITE])).toEqual({ + tools: [HELPER_TOOLS.API_WRITE], + }); + }); }); describe('storage tool auto-injection', () => { @@ -258,6 +264,45 @@ describe('get-actor-list selection (in no category)', () => { ); }); +// It changes or deletes data at any API path, so a session gets it only by naming it. +describe('apify-api-write selection (in no category)', () => { + const API_CATEGORY_TOOL_NAMES = [HELPER_TOOLS.API_SEARCH, HELPER_TOOLS.API_DETAILS, HELPER_TOOLS.API_READ]; + + it('does not treat tools=apify-api-write as an Actor name', () => { + expect(resolveActorsToLoad({ tools: [HELPER_TOOLS.API_WRITE] })).toEqual([]); + }); + + it.each(['default', 'apps'] as const)( + 'serves only apify-api-write for tools=apify-api-write in %s mode', + (mode) => { + const toolNames = getToolsForServerMode({ tools: [HELPER_TOOLS.API_WRITE] }, [], mode).map((t) => t.name); + expect(toolNames).toEqual([HELPER_TOOLS.API_WRITE]); + }, + ); + + it.each(['default', 'apps'] as const)( + 'serves search, details, and read without apify-api-write for tools=api in %s mode', + (mode) => { + const toolNames = getToolsForServerMode({ tools: ['api'] }, [], mode).map((t) => t.name); + expect(toolNames).toEqual(API_CATEGORY_TOOL_NAMES); + expect(getToolsForServerMode({}, [], mode).map((t) => t.name)).not.toContain(HELPER_TOOLS.API_WRITE); + }, + ); + + it.each(['default', 'apps'] as const)( + 'serves the api tools and apify-api-write for tools=api,apify-api-write in %s mode', + (mode) => { + const tools = getToolsForServerMode({ tools: ['api', HELPER_TOOLS.API_WRITE] }, [], mode); + const toolNames = tools.map((t) => t.name); + expect(toolNames).toEqual([...API_CATEGORY_TOOL_NAMES, HELPER_TOOLS.API_WRITE]); + + const searchTool = tools.find((t) => t.name === HELPER_TOOLS.API_SEARCH); + const { description } = getToolPublicFieldOnly(searchTool!, { presentTools: new Set(toolNames) }); + expect(description).toContain(`${HELPER_TOOLS.API_WRITE} for a POST, PUT, PATCH, or DELETE`); + }, + ); +}); + describe('loadToolsFromInput explicit widget selection', () => { const apifyClient = new ApifyClient({ token: 'test-token' }); From 8a61b5e20fa44be498aa421c883bb2d42bbfcd90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 10:03:31 +0200 Subject: [PATCH 054/103] fix: Ask apify-api-write for only the write methods of a path Without a method, a path with several methods got "The path matches methods GET, PUT, and DELETE; specify which one to call the endpoint with.", and a GET was then refused by the input schema. The prompt now lists only the path's write methods and says the tool does not send its GET, naming apify-api-read when the session has it. A path with only GET is still sent to the read tool. --- src/tools/api/apify_api_write.ts | 27 ++++++++++++------------- tests/unit/tools.apify_api.test.ts | 32 +++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index e7bd969f8..ee3017b26 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -56,7 +56,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { /** * The only method of the path in the spec, or why the agent must give one. Only a call without a - * method needs the spec. A path whose only method is GET is sent to the read tool, never as a GET here. + * method needs the spec. The agent is asked only for the write methods; a GET goes to the read tool. */ async function inferMethod( path: string, @@ -66,21 +66,20 @@ async function inferMethod( if (!index) return { error: 'The API spec could not be loaded to choose the method; specify the method.' }; const operations = findPathOperations(index, normalizeApiPath(path)); if (operations.length === 0) return { error: 'The path is not in the API spec; specify the method.' }; - if (operations.length > 1) { - const methods = formatList( - operations.map((operation) => operation.method), - 'and', - ); - return { error: `The path matches methods ${methods}; specify which one to call the endpoint with.` }; - } - const [{ method }] = operations; - if (method === 'GET') { - const readTool = loadedToolNames.includes(HELPER_TOOLS.API_READ) - ? `; call it with ${HELPER_TOOLS.API_READ}` - : ''; + const writeMethods = operations + .map((operation) => operation.method) + .filter((method): method is Exclude => method !== 'GET'); + const readTool = loadedToolNames.includes(HELPER_TOOLS.API_READ) ? `; call it with ${HELPER_TOOLS.API_READ}` : ''; + if (writeMethods.length === 0) { return { error: `The path has only the GET method, which this tool does not send${readTool}.` }; } - return { method }; + if (operations.length === 1) return { method: writeMethods[0] }; + const prompt = + writeMethods.length === 1 + ? `The path matches method ${writeMethods[0]}; specify it to call the endpoint with.` + : `The path matches methods ${formatList(writeMethods, 'and')}; specify which one to call the endpoint with.`; + const getNote = writeMethods.length < operations.length ? ` This tool does not send its GET${readTool}.` : ''; + return { error: `${prompt}${getNote}` }; } /** diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index fb535325b..12042dedb 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -737,7 +737,6 @@ describe('apify-api-write', () => { }); it.each([ - ['datasets/abc', 'The path matches methods GET, PUT, and DELETE; specify which one to call the endpoint with.'], [ 'request-queues/q/requests/batch', 'The path matches methods POST and DELETE; specify which one to call the endpoint with.', @@ -751,6 +750,37 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it.each([ + [ + 'datasets/abc', + 'The path matches methods PUT and DELETE; specify which one to call the endpoint with. This tool ' + + `does not send its GET; call it with ${HELPER_TOOLS.API_READ}.`, + ], + [ + 'webhooks', + 'The path matches method POST; specify it to call the endpoint with. This tool does not send its ' + + `GET; call it with ${HELPER_TOOLS.API_READ}.`, + ], + ])('asks only for a write method of %s and sends its GET to the read tool', async (path, message) => { + const result = await callTool(apifyApiWrite, { path, body: { name: 'x' } }, [ + HELPER_TOOLS.API_WRITE, + HELPER_TOOLS.API_READ, + ]); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(message); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('does not name the read tool for the GET of a path when the session lacks it', async () => { + const result = await callTool(apifyApiWrite, { path: 'datasets/abc' }, [HELPER_TOOLS.API_WRITE]); + + expect(result.content[0].text).toBe( + 'The path matches methods PUT and DELETE; specify which one to call the endpoint with. This tool ' + + 'does not send its GET.', + ); + }); + it('sends the body as application/json through the real apify-client axios instance, a string as the value it holds', async () => { const client = new ApifyClient({ token: 'test-token', baseUrl: 'https://api.apify.com' }); const sent: { url?: string; data?: unknown; contentType?: unknown }[] = []; From 786287efb442130ac8cd3117b12a5393b97ce505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:14:08 +0200 Subject: [PATCH 055/103] feat: Check the API tools' calls against a typed blocklist Add apify_api_blocklist.ts, the list of what the API call tools refuse (API_BLOCK_RULES), for #1501. A query parameter rule matches a parameter by name in the call tools it names; an operation rule matches methods and a spec path template, and refuses each method in the tool that sends it. Each rule has the reason shown to the agent and optionally a tool to suggest, named only when the session has it. callApi checks every call as the API reads the request URL, fail-closed: a path in any letter case, decoded segment by segment, with a trailing or doubled slash, and acts read as actors; a query name decoded, in any letter case, and with brackets. apify-api-write checks before it chooses a method, and apify-api-details before it looks the path up. buildApiOperationIndex leaves out what a rule refuses, so search, details, the closest-path hints, and the write tool's method choice never offer it. The list is empty here; the tests use test-only rules. --- src/tools/AGENTS.md | 17 +- src/tools/api/apify_api_blocklist.ts | 197 +++++++++++++++++++ src/tools/api/apify_api_details.ts | 11 +- src/tools/api/apify_api_request.ts | 11 +- src/tools/api/apify_api_spec.ts | 39 ++-- src/tools/api/apify_api_write.ts | 11 ++ tests/unit/tools.apify_api.test.ts | 38 ++++ tests/unit/tools.apify_api_blocklist.test.ts | 96 +++++++++ tests/unit/tools.apify_api_spec.test.ts | 39 ++++ 9 files changed, 440 insertions(+), 19 deletions(-) create mode 100644 src/tools/api/apify_api_blocklist.ts create mode 100644 tests/unit/tools.apify_api_blocklist.test.ts diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 69c69ac20..833d3d1cd 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -42,8 +42,21 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. - `api/` — the generic Apify API tools: search the operations of the published OpenAPI spec, get the operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The write tool is in no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write - tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI: - they refuse nothing the API accepts. + tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI. + They refuse what the blocklist in `apify_api_blocklist.ts` lists (`API_BLOCK_RULES`). Each rule names + what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the + session has it. A query parameter rule matches a parameter by name in the call tools it names. An + operation rule matches methods and a spec path template, and each method is refused in the tool that + sends it (GET: the read tool; any other: the write tool). `callApi` checks every call against the list + (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, + decoded segment by segment, with a trailing or doubled slash, and `acts` read as `actors`; a query + name decoded, in any letter case, and with brackets. The write tool checks before it chooses a method + too; without a method, an operation rule with a method the write tool sends refuses the path. + `applyApiBlocklist` leaves out of the index each operation a rule names and each query parameter a + rule refuses to the tool that sends the operation's method. So search, details, the closest-path + hints, and the write tool's method choice never offer them. Details checks the path, and the method + when given, before it looks the path up (`validateApiPathBlocklist`), so it refuses with the rule's + reason and never answers with a path next to a refused one. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts new file mode 100644 index 000000000..2519195e7 --- /dev/null +++ b/src/tools/api/apify_api_blocklist.ts @@ -0,0 +1,197 @@ +import type { HelperToolName } from '../../const.js'; +import { HELPER_TOOLS } from '../../const.js'; +import type { ApiMethod, ApiParameter } from './apify_api_spec.js'; + +/** The API tools that send requests, the ones a rule refuses calls in. */ +export type ApiCallToolName = typeof HELPER_TOOLS.API_READ | typeof HELPER_TOOLS.API_WRITE; + +type ApiBlockRuleBase = { + /** Why the call is refused, shown to the agent. */ + reason: string; + /** A tool to call instead, named only when the session has it and it is not the refusing tool. */ + suggestedToolName?: HelperToolName; +}; + +/** Matches a query parameter, in `query` or in a query string written into the path, by name. */ +export type ApiQueryParamBlockRule = ApiBlockRuleBase & { + queryParam: string; + /** The tools that refuse a call the rule matches. */ + toolNames: readonly ApiCallToolName[]; +}; + +/** + * Matches operations: HTTP methods and a path template as the spec writes it, such as `/v2/actors/{actorId}`. + * Each method is refused in the tool that sends it (`getApiCallToolName`). + */ +export type ApiOperationBlockRule = ApiBlockRuleBase & { + operation: { methods: readonly ApiMethod[]; path: `/v2/${string}` }; +}; + +export type ApiBlockRule = ApiQueryParamBlockRule | ApiOperationBlockRule; + +/** What the API tools refuse to send. The operation index leaves the same out, so search and details never offer it. */ +export const API_BLOCK_RULES: readonly ApiBlockRule[] = []; + +/** The tool that sends a method: the read tool sends only a GET, and the write tool every other method. */ +export function getApiCallToolName(method: ApiMethod): ApiCallToolName { + return method === 'GET' ? HELPER_TOOLS.API_READ : HELPER_TOOLS.API_WRITE; +} + +/** + * A text with each `%XX` escape decoded to that byte as a character. Unlike `decodeURIComponent`, it never + * throws, so a malformed escape elsewhere in the text cannot hide a name. + */ +function decodeEscapes(text: string): string { + return text.replace(/%([0-9a-f]{2})/gi, (_escape, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); +} + +/** + * The top-level name the API's query parser (qs, as Express runs it) reads from a query parameter name, or a + * broader one: decoded, in lowercase, and without brackets, since `method[]`, `method[0]`, and `[method]` all + * reach the API as `method`. + */ +function extractQueryParamRoot(name: string): string { + const decodedName = decodeEscapes(name).toLowerCase(); + return /^\[([^[\]]*)\]/.exec(decodedName)?.[1] ?? decodedName.split('[')[0]; +} + +function isBlockedQueryParam(name: string, rule: ApiQueryParamBlockRule): boolean { + return extractQueryParamRoot(name) === rule.queryParam.toLowerCase(); +} + +/** A path template segment that is a parameter, such as `{actorId}`. */ +export const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; + +/** + * The segments of a path as the API routes it: split first and then decoded one by one, so an escaped slash + * stays in its segment; in lowercase; without the empty segments of a trailing slash, or of a doubled one in + * case anything before the router merges it; and with the legacy `acts` prefix read as `actors`, the one the + * spec lists. + */ +function splitRoutePath(path: string): string[] { + const segments = path + .split('/') + .filter(Boolean) + .map((segment) => decodeEscapes(segment).toLowerCase()); + if (segments[0] === 'v2' && segments[1] === 'acts') segments[1] = 'actors'; + return segments; +} + +/** + * Whether the API routes a path, with values or as a spec template, to the operation of the rule. Without a + * method, the path alone decides. + */ +function isBlockedOperation(method: ApiMethod | undefined, path: string, rule: ApiOperationBlockRule): boolean { + if (method !== undefined && !rule.operation.methods.includes(method)) return false; + const templateSegments = splitRoutePath(rule.operation.path); + const segments = splitRoutePath(path); + if (segments.length !== templateSegments.length) return false; + // A parameter matches one segment, never an empty one: the split drops those. + return templateSegments.every( + (templateSegment, position) => + PATH_PARAMETER_SEGMENT_REGEX.test(templateSegment) || segments[position] === templateSegment, + ); +} + +/** A normalized path parsed as the request URL is. Any origin will do; only the path and the query are checked. */ +function parseRequestUrl(normalizedPath: string): URL { + return new URL(`https://api.invalid/v2/${normalizedPath}`); +} + +/** The reason of a rule, with the tool it suggests when the session has it and it is not the refusing tool. */ +function formatRefusal( + { reason, suggestedToolName }: ApiBlockRule, + toolName: ApiCallToolName | undefined, + loadedToolNames: readonly string[], +): string { + const canSuggest = + suggestedToolName !== undefined && + suggestedToolName !== toolName && + loadedToolNames.includes(suggestedToolName); + return canSuggest ? `${reason} Use ${suggestedToolName} instead.` : reason; +} + +/** + * Why a rule refuses a call, or `undefined` when none does. The path is parsed as the request URL is: dot + * segments resolved, a backslash read as a slash, tabs and newlines dropped. The match is fail-closed: a + * path matches in any letter case and with a trailing or doubled slash, and a query name matches decoded, in + * any letter case, and with brackets. Without a method (the write tool checks before it chooses one), an + * operation rule with a method the tool sends refuses the path; `callApi` checks again with the method. + */ +export function validateApiBlocklist( + { + toolName, + method, + normalizedPath, + query, + loadedToolNames, + }: { + toolName: ApiCallToolName; + method: ApiMethod | undefined; + /** The path as `normalizeApiPath` returns it, with any query string written into it. */ + normalizedPath: string; + query?: Record; + loadedToolNames: readonly string[]; + }, + rules: readonly ApiBlockRule[] = API_BLOCK_RULES, +): string | undefined { + const { pathname, searchParams } = parseRequestUrl(normalizedPath); + const queryNames = [...searchParams.keys(), ...Object.keys(query ?? {})]; + const rule = rules.find((candidate) => + 'queryParam' in candidate + ? candidate.toolNames.includes(toolName) && queryNames.some((name) => isBlockedQueryParam(name, candidate)) + : candidate.operation.methods.some((ruleMethod) => getApiCallToolName(ruleMethod) === toolName) && + isBlockedOperation(method, pathname, candidate), + ); + return rule && formatRefusal(rule, toolName, loadedToolNames); +} + +/** + * Why the API tools refuse calls to a path in a method, or `undefined` when no operation rule matches it; + * without a method, the path alone decides. For a tool that reads the index instead of calling, such as + * apify-api-details: the index leaves out what a rule refuses, so a lookup there could match a path next + * to it or say the method is missing. The path is parsed as in `validateApiBlocklist`. + */ +export function validateApiPathBlocklist( + { + normalizedPath, + method, + loadedToolNames, + }: { + /** The path as `normalizeApiPath` returns it. */ + normalizedPath: string; + method?: ApiMethod; + loadedToolNames: readonly string[]; + }, + rules: readonly ApiBlockRule[] = API_BLOCK_RULES, +): string | undefined { + const { pathname } = parseRequestUrl(normalizedPath); + const rule = rules.find((candidate) => 'operation' in candidate && isBlockedOperation(method, pathname, candidate)); + return rule && formatRefusal(rule, undefined, loadedToolNames); +} + +/** + * An operation of the spec as the API tools offer it: `undefined` when a rule refuses it to the tool that sends + * its method, and otherwise without the query parameters a rule refuses to that tool. + */ +export function applyApiBlocklist( + operation: TOperation, + rules: readonly ApiBlockRule[] = API_BLOCK_RULES, +): TOperation | undefined { + const isBlocked = rules.some( + (rule) => 'operation' in rule && isBlockedOperation(operation.method, operation.path, rule), + ); + if (isBlocked) return undefined; + const toolName = getApiCallToolName(operation.method); + const parameters = operation.parameters.filter( + (parameter) => + parameter.in !== 'query' || + !rules.some( + (rule) => + 'queryParam' in rule && + rule.toolNames.includes(toolName) && + isBlockedQueryParam(parameter.name, rule), + ), + ); + return { ...operation, parameters }; +} diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 130694eb4..a54020e11 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,6 +7,7 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; +import { validateApiPathBlocklist } from './apify_api_blocklist.js'; import { findPathOperations, formatApiPath, @@ -69,8 +70,16 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); - const index = await fetchApiOperationIndex(); const normalizedPath = normalizeApiPath(parsed.path); + // Before the lookup: the index leaves out the operations the call tools refuse, so the lookup could + // match a path next to a refused one or say a refused method is missing. + const refusal = validateApiPathBlocklist({ + normalizedPath, + method: parsed.method, + loadedToolNames: toolArgs.loadedToolNames, + }); + if (refusal) return respondUserError(refusal); + const index = await fetchApiOperationIndex(); const path = formatApiPath(normalizedPath); const matched = findPathOperations(index, normalizedPath); if (matched.length === 0) { diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 58a8998f5..188bbd3ae 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -12,6 +12,7 @@ import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_reso import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; +import { getApiCallToolName, PATH_PARAMETER_SEGMENT_REGEX, validateApiBlocklist } from './apify_api_blocklist.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -92,8 +93,6 @@ function toSpecPath(normalizedPath: string): string { return stripQuery(normalizedPath).replace(/^acts(?=\/|$)/, 'actors'); } -const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; - /** How many literal segments of the template match the path segments, or `undefined` when it does not match. */ function countMatchingLiterals(template: string, segments: string[]): number | undefined { const templateSegments = normalizeApiPath(template).split('/'); @@ -283,6 +282,14 @@ export async function callApi(params: { }): Promise { const { client, method } = params; const normalizedPath = normalizeApiPath(params.path); + const refusal = validateApiBlocklist({ + toolName: getApiCallToolName(method), + method, + normalizedPath, + query: params.query, + loadedToolNames: params.loadedToolNames, + }); + if (refusal) return respondUserError(refusal); const path = formatApiPath(normalizedPath); // `client.baseUrl` already ends with /v2. const url = `${client.baseUrl}/${normalizedPath}`; diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 5348debd1..fdb86ff94 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import { apifyApiOperationsCache } from '../../state.js'; +import type { ApiBlockRule } from './apify_api_blocklist.js'; +import { API_BLOCK_RULES, applyApiBlocklist } from './apify_api_blocklist.js'; export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; @@ -142,11 +144,16 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ } /** - * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, - * and anything outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. + * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, and anything + * outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. So are the + * operations and query parameters a rule refuses to the tool that sends the method (`applyApiBlocklist`), + * so no tool that reads the index offers them. * It throws when the spec lists no operation. */ -export function buildApiOperationIndex(spec: unknown): Map { +export function buildApiOperationIndex( + spec: unknown, + rules: readonly ApiBlockRule[] = API_BLOCK_RULES, +): Map { const index = new Map(); const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { @@ -156,17 +163,21 @@ export function buildApiOperationIndex(spec: unknown): Map if (!parsed.success || parsed.data.deprecated) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); - index.set(operationId, { - operationId, - method, - path, - summary: summary ?? '', - description: description ?? '', - docsUrl: `${API_DOCS_BASE_URL}/${splitWords(operationId).join('-')}`, - tags: tags ?? [], - parameters: parseParameters(parameters, spec), - ...(body && { requestBody: body }), - }); + const operation = applyApiBlocklist( + { + operationId, + method, + path, + summary: summary ?? '', + description: description ?? '', + docsUrl: `${API_DOCS_BASE_URL}/${splitWords(operationId).join('-')}`, + tags: tags ?? [], + parameters: parseParameters(parameters, spec), + ...(body && { requestBody: body }), + }, + rules, + ); + if (operation) index.set(operationId, operation); } } if (index.size === 0) throw new Error('the spec lists no /v2/ operations.'); diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index ee3017b26..0650fe38f 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -7,6 +7,7 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; +import { validateApiBlocklist } from './apify_api_blocklist.js'; import { API_CALL_DESCRIPTION, apiCallArgsShape, @@ -120,6 +121,16 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); + // Before the method is inferred: the spec leaves a refused path out, so inference would ask for a method. + // callApi checks again, with the method. + const refusal = validateApiBlocklist({ + toolName: HELPER_TOOLS.API_WRITE, + method: parsed.method, + normalizedPath: normalizeApiPath(parsed.path), + query: parsed.query, + loadedToolNames: toolArgs.loadedToolNames, + }); + if (refusal) return respondUserError(refusal); const body = parseBody(parsed.body); if ('error' in body) return respondUserError(body.error); const resolved = parsed.method diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 12042dedb..10550ee2a 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApifyClient } from '../../src/apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; +import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; +import { API_BLOCK_RULES } from '../../src/tools/api/apify_api_blocklist.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; import { apifyApiRead } from '../../src/tools/api/apify_api_read.js'; import { @@ -247,6 +249,42 @@ describe('apify-api-details', () => { } }); + describe('with a test-only rule in the published list', () => { + /** Calls the tool while the rule is in the list, so the index the tool loads leaves out what it names. */ + async function callDetailsWithRule(rule: ApiBlockRule, args: Record) { + const rules = API_BLOCK_RULES as ApiBlockRule[]; + rules.push(rule); + try { + return await callTool(apifyApiDetails, args); + } finally { + rules.pop(); + } + } + + it('refuses a literal path a rule names, not the parameter path next to it', async () => { + const rule: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/users/me' }, reason: 'No.' }; + + const result = await callDetailsWithRule(rule, { path: 'users/me' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe('No.'); + }); + + it('refuses a method a rule names and returns the methods it does not name', async () => { + const rule: ApiBlockRule = { + operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, + reason: 'No.', + }; + + const refused = await callDetailsWithRule(rule, { path: 'datasets/abc', method: 'GET' }); + const allowed = await callDetailsWithRule(rule, { path: 'datasets/abc', method: 'PUT' }); + + expectSoftFailInvalidInput(refused); + expect(refused.content[0].text).toBe('No.'); + expect(allowed.content[1].text).toBe('/v2/datasets/{datasetId}: PUT.'); + }); + }); + it('names the search tool on a path not in the spec only when the session has it', async () => { const withSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }); const withoutSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }, [ diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts new file mode 100644 index 000000000..f66737ef5 --- /dev/null +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { HELPER_TOOLS } from '../../src/const.js'; +import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; +import { validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; + +/** A test-only operation rule. */ +const OPERATION_RULE: ApiBlockRule = { + operation: { methods: ['GET'], path: '/v2/actors/{actorId}/run-now' }, + reason: 'No run-now.', +}; + +/** Validates a GET from the read tool in a session with no other tools, unless the params say otherwise. */ +function validate( + params: Partial[0]> & { normalizedPath: string }, + rules?: readonly ApiBlockRule[], +): string | undefined { + return validateApiBlocklist( + { toolName: HELPER_TOOLS.API_READ, method: 'GET', loadedToolNames: [], ...params }, + rules, + ); +} + +describe('validateApiBlocklist()', () => { + describe('an operation rule', () => { + it.each([ + 'actors/abc/run-now', + 'actors/abc/run-now?limit=1', + // The API routes paths case-insensitively and ignores a trailing slash. + 'Actors/ABC/Run-Now', + 'actors/abc/run-now/', + // The API routes the legacy acts prefix like actors. + 'acts/john~my-actor/run-now', + 'ACTS/abc/run-now', + // A slash encoded in a name is decoded after routing, so the name is one segment. + 'actors/apify%2Fhello-world/run-now', + // The URL is resolved before it is sent: dot segments, a backslash, and a newline. + 'actors/abc/x/../run-now', + 'actors/abc/%2e%2e/abc/run-now', + 'actors\\abc\\run-now', + 'actors/abc/run-\nnow', + // Escapes in a literal segment are decoded, in case anything before the router decodes them. + 'actors/abc/run%2Dnow', + // Empty segments are ignored, in case anything before the router merges doubled slashes. + 'actors//abc//run-now', + 'actors/abc/run-now//', + ])('refuses the method and path template it names for %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [OPERATION_RULE])).toBe('No run-now.'); + }); + + it.each([ + 'actors/abc/run-now-x', + 'actors/run-now', + 'actors//run-now', + 'actors/apify/hello-world/run-now', + 'actor-runs/abc/run-now', + 'actsx/abc/run-now', + 'actors/abc/run-now/x', + ])('does not refuse %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [OPERATION_RULE])).toBeUndefined(); + }); + + it('refuses each method it names in the tool that sends that method, and no other method', () => { + const rule: ApiBlockRule = { + operation: { methods: ['GET', 'DELETE'], path: '/v2/actors/{actorId}/run-now' }, + reason: 'No run-now.', + }; + const normalizedPath = 'actors/abc/run-now'; + const write = { normalizedPath, toolName: HELPER_TOOLS.API_WRITE }; + + expect(validate({ normalizedPath }, [rule])).toBe('No run-now.'); + expect(validate({ ...write, method: 'DELETE' }, [rule])).toBe('No run-now.'); + expect(validate({ ...write, method: 'POST' }, [rule])).toBeUndefined(); + }); + + it('refuses the path in any method when the method is not chosen yet, in a tool that sends one it names', () => { + const normalizedPath = 'actors/abc/run-now'; + + expect(validate({ normalizedPath, method: undefined }, [OPERATION_RULE])).toBe('No run-now.'); + // The write tool never sends the rule's GET. + expect( + validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: undefined }, [OPERATION_RULE]), + ).toBeUndefined(); + }); + + it('names the suggested tool only when the session has it', () => { + const rule: ApiBlockRule = { ...OPERATION_RULE, suggestedToolName: HELPER_TOOLS.ACTOR_CALL }; + const normalizedPath = 'actors/abc/run-now'; + + expect(validate({ normalizedPath, loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] }, [rule])).toBe( + `No run-now. Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, + ); + expect(validate({ normalizedPath }, [rule])).toBe('No run-now.'); + }); + }); +}); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index ac2e3610a..8ac143785 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HELPER_TOOLS } from '../../src/const.js'; +import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { APIFY_API_OPENAPI_URL, buildApiOperationIndex, @@ -18,6 +20,43 @@ describe('buildApiOperationIndex()', () => { expect(index.size).toBe(38); }); + it('leaves out the operations a rule names', () => { + const rules: ApiBlockRule[] = [ + { operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, reason: 'No.' }, + { operation: { methods: ['DELETE'], path: '/v2/actors/{actorId}' }, reason: 'No.' }, + // A literal segment matches only itself, not a parameter of the spec. + { operation: { methods: ['GET'], path: '/v2/users/me' }, reason: 'No.' }, + ]; + + const blockedIndex = buildApiOperationIndex(API_SPEC_FIXTURE, rules); + + expect([...index.keys()].filter((operationId) => !blockedIndex.has(operationId))).toEqual([ + 'dataset_get', + 'actor_delete', + 'users_me_get', + ]); + }); + + it('drops a query parameter only from the operations of the tools its rule names', () => { + const rules: ApiBlockRule[] = [ + { queryParam: 'limit', toolNames: [HELPER_TOOLS.API_WRITE], reason: 'Refused.' }, + ]; + const parameters = [{ name: 'limit', in: 'query' }]; + const spec = { + paths: { + '/v2/things': { + get: { operationId: 'things_get', parameters }, + post: { operationId: 'things_post', parameters }, + }, + }, + }; + + const thingIndex = buildApiOperationIndex(spec, rules); + + expect(thingIndex.get('things_get')?.parameters.map(({ name }) => name)).toEqual(['limit']); + expect(thingIndex.get('things_post')?.parameters).toEqual([]); + }); + it('indexes a PATCH operation, which the published spec does not have yet', () => { const spec = { paths: { '/v2/things/{thingId}': { patch: { operationId: 'thing_patch', summary: 'Patch thing' } } }, From c5a4842519015e886e40b6bd363b92161f53632b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:14:41 +0200 Subject: [PATCH 056/103] fix: Refuse the method query parameter in the API call tools The API takes a method query parameter as the HTTP method of the request, so apify-api-read could delete with a GET to datasets/abc?method=DELETE, and apify-api-write could send another method than the one it named. Add the first blocklist rule: both call tools refuse the method query parameter, in query or written into the path, decoded, in any letter case, and with brackets, and suggest apify-api-write when the session has it. The operation index drops the parameter, so details never offers it. Fixes #1501 --- README.md | 4 +- src/tools/AGENTS.md | 22 +++--- src/tools/api/apify_api_blocklist.ts | 12 +++- src/tools/api/apify_api_spec.ts | 2 +- tests/unit/tools.apify_api.test.ts | 59 +++++++++++++++ tests/unit/tools.apify_api_blocklist.test.ts | 76 +++++++++++++++++++- tests/unit/tools.apify_api_spec.test.ts | 23 ++++++ 7 files changed, 183 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4ebaa2c0f..751a2f424 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,8 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. | | -| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. | | +| `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | +| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 833d3d1cd..80bbc4019 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -47,16 +47,18 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the session has it. A query parameter rule matches a parameter by name in the call tools it names. An operation rule matches methods and a spec path template, and each method is refused in the tool that - sends it (GET: the read tool; any other: the write tool). `callApi` checks every call against the list - (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, - decoded segment by segment, with a trailing or doubled slash, and `acts` read as `actors`; a query - name decoded, in any letter case, and with brackets. The write tool checks before it chooses a method - too; without a method, an operation rule with a method the write tool sends refuses the path. - `applyApiBlocklist` leaves out of the index each operation a rule names and each query parameter a - rule refuses to the tool that sends the operation's method. So search, details, the closest-path - hints, and the write tool's method choice never offer them. Details checks the path, and the method - when given, before it looks the path up (`validateApiPathBlocklist`), so it refuses with the rule's - reason and never answers with a path next to a refused one. + sends it (GET: the read tool; any other: the write tool). The one rule is the `method` query parameter, + which the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the + method it names. `callApi` checks every call against the list (`validateApiBlocklist`) as the API + reads the request URL, and fail-closed: a path in any letter case, decoded segment by segment, with a + trailing or doubled slash, and `acts` read as `actors`; a query name decoded, in any letter case, and + with brackets. The write tool checks before it chooses a method too; without a method, an operation + rule with a method the write tool sends refuses the path. `applyApiBlocklist` leaves out of the index + each operation a rule names and each query parameter a rule refuses to the tool that sends the + operation's method. So search, details, the closest-path hints, and the write tool's method choice + never offer them. Details checks the path, and the method when given, before it looks the path up + (`validateApiPathBlocklist`), so it refuses with the rule's reason and never answers with a path next + to a refused one. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 2519195e7..fb4182420 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -30,7 +30,17 @@ export type ApiOperationBlockRule = ApiBlockRuleBase & { export type ApiBlockRule = ApiQueryParamBlockRule | ApiOperationBlockRule; /** What the API tools refuse to send. The operation index leaves the same out, so search and details never offer it. */ -export const API_BLOCK_RULES: readonly ApiBlockRule[] = []; +export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ + { + // A GET with method=DELETE deletes, so the read tool could write; see apify/apify-mcp-server#1501. + queryParam: 'method', + toolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], + reason: + 'The API tools do not send the method query parameter: the API would take it as the HTTP method ' + + 'of the request.', + suggestedToolName: HELPER_TOOLS.API_WRITE, + }, +]; /** The tool that sends a method: the read tool sends only a GET, and the write tool every other method. */ export function getApiCallToolName(method: ApiMethod): ApiCallToolName { diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index fdb86ff94..4c3b32feb 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -147,7 +147,7 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, and anything * outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. So are the * operations and query parameters a rule refuses to the tool that sends the method (`applyApiBlocklist`), - * so no tool that reads the index offers them. + * such as the `method` query parameter, so no tool that reads the index offers them. * It throws when the spec lists no operation. */ export function buildApiOperationIndex( diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 10550ee2a..5e18f6dcd 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -78,6 +78,9 @@ afterEach(() => { vi.unstubAllEnvs(); }); +const METHOD_PARAM_REFUSAL = + 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; + /** The URL axios sends for the last request, with the query parameters added. */ function readSentUrl(): string { const [config] = requestMock.mock.lastCall as [{ url: string; params?: Record }]; @@ -320,6 +323,48 @@ describe('apify-api-read', () => { expect(withoutCallActor).not.toContain(HELPER_TOOLS.ACTOR_CALL); }); + it.each([ + { path: 'datasets/abc', query: { method: 'DELETE' } }, + { path: 'datasets/abc', query: { METHOD: 'delete' } }, + { path: 'datasets/abc', query: { '%6Dethod': 'Delete' } }, + { path: 'datasets/abc', query: { 'method[]': 'DELETE' } }, + { path: 'datasets/abc', query: { 'method[0]': 'POST' } }, + { path: 'datasets/abc', query: { 'Method[x]': 'POST' } }, + // The API's query parser reads a leading bracket pair as the name. + { path: 'datasets/abc', query: { '[method]': 'DELETE' } }, + { path: 'datasets/abc?method=delete' }, + { path: 'datasets/abc?METHOD=DELETE' }, + // The API decodes the names in the query string before it reads them. + { path: 'datasets/abc?limit=1&%6Dethod=DELETE' }, + { path: 'datasets/abc?method%5B%5D=DELETE' }, + { path: 'datasets/abc?method[0]=DELETE' }, + { path: 'datasets/abc?%5Bmethod%5D=DELETE' }, + // The URL drops a tab before it is sent. + { path: 'datasets/abc?me\tthod=DELETE' }, + ])('refuses the method query parameter in %j without a request', async (args) => { + const result = await callTool(apifyApiRead, args); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(`${METHOD_PARAM_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('names the write tool in the refusal of the method query parameter only when the session has it', async () => { + const result = await callTool(apifyApiRead, { path: 'datasets/abc?method=DELETE' }, [HELPER_TOOLS.API_READ]); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(METHOD_PARAM_REFUSAL); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('sends a query parameter whose name only starts with method', async () => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); + + await callTool(apifyApiRead, { path: 'datasets/abc?methods=a', query: { methodName: 'b' } }); + + expect(readSentUrl()).toBe(`${BASE_URL}/datasets/abc?methods=a&methodName=b`); + }); + it('sends one GET to the path and returns the body as the API sends it', async () => { const body = { data: { total: 1, items: [{ id: 'abc' }] } }; requestMock.mockResolvedValue(mockResponse(200, body)); @@ -732,6 +777,20 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it.each([ + { path: 'datasets/abc', method: 'PUT', query: { method: 'DELETE' }, body: { name: 'x' } }, + { path: 'datasets/abc', method: 'PUT', query: { '[METHOD]': 'DELETE' }, body: { name: 'x' } }, + { path: 'datasets/abc?method=DELETE', body: { name: 'x' } }, + { path: 'datasets/abc?%6Dethod%5B%5D=delete', method: 'PUT', body: { name: 'x' } }, + ])('refuses the method query parameter in %j without a request or the spec', async (args) => { + const result = await callTool(apifyApiWrite, args); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(METHOD_PARAM_REFUSAL); + expect(requestMock).not.toHaveBeenCalled(); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index f66737ef5..9f2d2b90a 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; -import { validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { validateApiBlocklist, validateApiPathBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; + +const METHOD_PARAM_REFUSAL = + 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; /** A test-only operation rule. */ const OPERATION_RULE: ApiBlockRule = { @@ -22,6 +25,68 @@ function validate( } describe('validateApiBlocklist()', () => { + describe('the method query parameter rule', () => { + it.each([ + { normalizedPath: 'datasets/abc', query: { method: 'DELETE' } }, + { normalizedPath: 'datasets/abc', query: { METHOD: 'delete' } }, + { normalizedPath: 'datasets/abc', query: { '%6Dethod': 'Delete' } }, + { normalizedPath: 'datasets/abc', query: { 'method[]': 'DELETE' } }, + { normalizedPath: 'datasets/abc', query: { 'method[0]': 'DELETE' } }, + { normalizedPath: 'datasets/abc', query: { 'Method[x]': 'DELETE' } }, + // The API's query parser reads a leading bracket pair as the name. + { normalizedPath: 'datasets/abc', query: { '[method]': 'DELETE' } }, + { normalizedPath: 'datasets/abc?method=DELETE' }, + { normalizedPath: 'datasets/abc?MeThOd=delete' }, + { normalizedPath: 'datasets/abc?limit=1&%6Dethod=DELETE' }, + { normalizedPath: 'datasets/abc?method%5B%5D=DELETE' }, + { normalizedPath: 'datasets/abc?method[0]=DELETE' }, + { normalizedPath: 'datasets/abc?%5Bmethod%5D=DELETE' }, + { normalizedPath: 'datasets/abc?[METHOD][x]=DELETE' }, + // The URL drops a tab before it is sent. + { normalizedPath: 'datasets/abc?me\tthod=DELETE' }, + ])('refuses %j in both call tools', (params) => { + expect(validate(params)).toBe(METHOD_PARAM_REFUSAL); + expect(validate({ ...params, toolName: HELPER_TOOLS.API_WRITE, method: 'PUT' })).toBe(METHOD_PARAM_REFUSAL); + }); + + it.each([ + { normalizedPath: 'datasets/abc?methods=a&limit=1', query: { methodName: 'b' } }, + { normalizedPath: 'datasets/abc', query: { 'filter[method]': 'b', 'me thod': 'c' } }, + { normalizedPath: 'datasets/abc?x=method', query: { x: 'method' } }, + // A fragment is not sent. + { normalizedPath: 'datasets/abc#?method=DELETE' }, + ])('does not refuse %j', (params) => { + expect(validate(params)).toBeUndefined(); + }); + + it('checks the query names without the method, before the write tool chooses one', () => { + expect( + validate({ + toolName: HELPER_TOOLS.API_WRITE, + method: undefined, + normalizedPath: 'datasets/abc?method=PUT', + }), + ).toBe(METHOD_PARAM_REFUSAL); + }); + }); + + it('names the suggested tool only when the session has it and it is not the refusing tool', () => { + const params = { normalizedPath: 'datasets/abc', query: { method: 'DELETE' } }; + + expect(validate({ ...params, loadedToolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE] })).toBe( + `${METHOD_PARAM_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`, + ); + expect(validate({ ...params, loadedToolNames: [HELPER_TOOLS.API_READ] })).toBe(METHOD_PARAM_REFUSAL); + expect( + validate({ + ...params, + toolName: HELPER_TOOLS.API_WRITE, + method: 'PUT', + loadedToolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], + }), + ).toBe(METHOD_PARAM_REFUSAL); + }); + describe('an operation rule', () => { it.each([ 'actors/abc/run-now', @@ -94,3 +159,12 @@ describe('validateApiBlocklist()', () => { }); }); }); + +describe('validateApiPathBlocklist()', () => { + it('checks only the operation rules', () => { + expect(validateApiPathBlocklist({ normalizedPath: 'actors/abc/runs', loadedToolNames: [] })).toBeUndefined(); + expect( + validateApiPathBlocklist({ normalizedPath: 'not-in-spec?method=DELETE', loadedToolNames: [] }), + ).toBeUndefined(); + }); +}); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 8ac143785..f7e623877 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -37,6 +37,29 @@ describe('buildApiOperationIndex()', () => { ]); }); + it('drops the method query parameter, which the API takes as the HTTP method, from every operation', () => { + const spec = { + paths: { + '/v2/things/{thingId}': { + get: { + operationId: 'thing_get', + parameters: [ + { name: 'thingId', in: 'path', required: true }, + { name: 'method', in: 'query' }, + { name: 'limit', in: 'query' }, + ], + }, + post: { operationId: 'thing_post', parameters: [{ name: 'Method[]', in: 'query' }] }, + }, + }, + }; + + const thingIndex = buildApiOperationIndex(spec); + + expect(thingIndex.get('thing_get')?.parameters.map(({ name }) => name)).toEqual(['thingId', 'limit']); + expect(thingIndex.get('thing_post')?.parameters).toEqual([]); + }); + it('drops a query parameter only from the operations of the tools its rule names', () => { const rules: ApiBlockRule[] = [ { queryParam: 'limit', toolNames: [HELPER_TOOLS.API_WRITE], reason: 'Refused.' }, From 00be4834f251d02ea350776f2bb4f1514d9eee27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:15:53 +0200 Subject: [PATCH 057/103] fix: Refuse the synchronous run endpoints in the API tools A GET to an Actor's or a task's run-sync or run-sync-get-dataset-items endpoint starts a paid run, so apify-api-read could start one, and the run can outlast the tool-call timeout. Add blocklist rules for those four paths (GET and POST, also under the legacy acts prefix): apify-api-read refuses the GET, apify-api-write the POST, and apify-api-details the path. A refusal points to an asynchronous run and suggests call-actor for an Actor when the session has it. The operation index leaves the endpoints out, so search, details, and the closest-path hints never offer them, and the search ranking no longer needs to rank them below a storage's own operation. The tool descriptions, README, and AGENTS.md say they are refused. Fixes #1502 --- README.md | 4 +- src/tools/AGENTS.md | 23 +-- src/tools/api/apify_api_blocklist.ts | 29 ++++ src/tools/api/apify_api_read.ts | 5 +- src/tools/api/apify_api_request.ts | 4 +- src/tools/api/apify_api_spec.ts | 10 +- tests/unit/helpers/apify_api_spec_fixture.ts | 5 +- tests/unit/tools.apify_api.test.ts | 152 ++++++++++++++++++- tests/unit/tools.apify_api_blocklist.test.ts | 90 +++++++++++ tests/unit/tools.apify_api_spec.test.ts | 33 +++- 10 files changed, 320 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 751a2f424..0e1b3d2b3 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,8 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to any Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | -| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | +| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. It refuses the synchronous run endpoints and the `method` query parameter. | | +| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to an Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the synchronous run endpoints and the `method` query parameter. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 80bbc4019..22ea46df2 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -47,18 +47,19 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the session has it. A query parameter rule matches a parameter by name in the call tools it names. An operation rule matches methods and a spec path template, and each method is refused in the tool that - sends it (GET: the read tool; any other: the write tool). The one rule is the `method` query parameter, + sends it (GET: the read tool; any other: the write tool). The rules: the `method` query parameter, which the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the - method it names. `callApi` checks every call against the list (`validateApiBlocklist`) as the API - reads the request URL, and fail-closed: a path in any letter case, decoded segment by segment, with a - trailing or doubled slash, and `acts` read as `actors`; a query name decoded, in any letter case, and - with brackets. The write tool checks before it chooses a method too; without a method, an operation - rule with a method the write tool sends refuses the path. `applyApiBlocklist` leaves out of the index - each operation a rule names and each query parameter a rule refuses to the tool that sends the - operation's method. So search, details, the closest-path hints, and the write tool's method choice - never offer them. Details checks the path, and the method when given, before it looks the path up - (`validateApiPathBlocklist`), so it refuses with the rule's reason and never answers with a path next - to a refused one. + method it names; and the synchronous run endpoints of Actors and tasks (GET and POST), since a run + there is paid and can outlast the tool-call timeout. `callApi` checks every call against the list + (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, + decoded segment by segment, with a trailing or doubled slash, and `acts` read as `actors`; a query + name decoded, in any letter case, and with brackets. The write tool checks before it chooses a method + too; without a method, an operation rule with a method the write tool sends refuses the path. + `applyApiBlocklist` leaves out of the index each operation a rule names and each query parameter a + rule refuses to the tool that sends the operation's method. So search, details, the closest-path + hints, and the write tool's method choice never offer them. Details checks the path, and the method + when given, before it looks the path up (`validateApiPathBlocklist`), so it refuses with the rule's + reason and never answers with a path next to a refused one. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index fb4182420..854f17946 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -29,6 +29,22 @@ export type ApiOperationBlockRule = ApiBlockRuleBase & { export type ApiBlockRule = ApiQueryParamBlockRule | ApiOperationBlockRule; +/** The synchronous run endpoints of an Actor, also under the legacy `acts` prefix. */ +const ACTOR_SYNC_RUN_PATHS = [ + '/v2/actors/{actorId}/run-sync', + '/v2/actors/{actorId}/run-sync-get-dataset-items', +] as const; + +/** The synchronous run endpoints of a task. */ +const TASK_SYNC_RUN_PATHS = [ + '/v2/actor-tasks/{actorTaskId}/run-sync', + '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', +] as const; + +const SYNC_RUN_REASON = + 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + + 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; + /** What the API tools refuse to send. The operation index leaves the same out, so search and details never offer it. */ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ { @@ -40,6 +56,19 @@ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ 'of the request.', suggestedToolName: HELPER_TOOLS.API_WRITE, }, + // A GET or a POST there starts a paid run, which can outlast the tool-call timeout. The API takes no other + // method there but HEAD, which the tools never send. See apify/apify-mcp-server#1502. + ...ACTOR_SYNC_RUN_PATHS.map( + (path): ApiOperationBlockRule => ({ + operation: { methods: ['GET', 'POST'], path }, + reason: SYNC_RUN_REASON, + suggestedToolName: HELPER_TOOLS.ACTOR_CALL, + }), + ), + // call-actor runs an Actor, not a saved task, so these suggest no tool. + ...TASK_SYNC_RUN_PATHS.map( + (path): ApiOperationBlockRule => ({ operation: { methods: ['GET', 'POST'], path }, reason: SYNC_RUN_REASON }), + ), ]; /** The tool that sends a method: the read tool sends only a GET, and the write tool every other method. */ diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index ddb6cc01e..83b59091c 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -17,8 +17,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { : ''; const runActor = hasTool(HELPER_TOOLS.ACTOR_CALL) ? ` Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.` : ''; return dedent` - Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc. - A GET can start a paid Actor run, as the synchronous run endpoints do.${runActor} + Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc.${runActor} ${API_CALL_DESCRIPTION}${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. @@ -54,7 +53,7 @@ export const apifyApiRead: ToolEntry = Object.freeze({ idempotentHint: false, openWorldHint: false, }, - // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. + // A storage signature would otherwise be logged. redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 188bbd3ae..86dd5a629 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -36,8 +36,8 @@ export const apiCallArgsShape = { export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. Write the path as actors, v2/actors, or /v2/actors, with its values in it. A name is written username~name, or ~name for one you own, as in /v2/actors/~my-actor. -Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, -so prefer asynchronous runs.`; +The synchronous run endpoints are refused: a run can outlast the usual 60-second tool-call timeout. +For the same reason, keep waitForFinish at or below ${WAIT_SECS_MAX} seconds.`; /** Query parameters whose values grant access or carry secrets; `webhooks` can hold webhook headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 4c3b32feb..c9bbc6744 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -147,7 +147,7 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, and anything * outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. So are the * operations and query parameters a rule refuses to the tool that sends the method (`applyApiBlocklist`), - * such as the `method` query parameter, so no tool that reads the index offers them. + * such as the synchronous run endpoints, so no tool that reads the index offers them. * It throws when the spec lists no operation. */ export function buildApiOperationIndex( @@ -284,11 +284,11 @@ function hasWholeWord(term: string, words: string[]): boolean { /** * Paths that reach a run's own copy of a resource: the default storages and actions of a run or of the - * last run, and the synchronous runs. A plain ask such as "add items to a dataset" means the storage - * itself, so these rank lower unless the query is about runs or tasks. + * last run. A plain ask such as "add items to a dataset" means the storage itself, so these rank lower + * unless the query is about runs or tasks. */ -const RUN_SCOPED_PATH_REGEX = /^\/v2\/actor-runs\/\{runId\}\/|\/runs\/last\/|\/run-sync/; -const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'task', 'tasks', 'sync']); +const RUN_SCOPED_PATH_REGEX = /^\/v2\/actor-runs\/\{runId\}\/|\/runs\/last\//; +const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'task', 'tasks']); const RUN_SCOPED_PENALTY = 2; /** diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index 2971728e9..e51aba953 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -159,8 +159,8 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset - // in their summary: search must still rank the storage's own operation first. + // A run's copy of the dataset operation, which names the dataset in its summary: search must + // still rank the storage's own operation first. '/v2/actors/{actorId}/runs/last/dataset/items': { post: { operationId: 'actor_runs_last_dataset_items_post', @@ -170,6 +170,7 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, }, }, + // A synchronous run, which the index leaves out. '/v2/actors/{actorId}/run-sync-get-dataset-items': { post: { operationId: 'actor_runSyncGetDatasetItems_post', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 5e18f6dcd..391b18646 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApifyClient } from '../../src/apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; +import { WAIT_SECS_MAX } from '../../src/tools/actors/actor_run_response.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { API_BLOCK_RULES } from '../../src/tools/api/apify_api_blocklist.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; @@ -78,6 +79,10 @@ afterEach(() => { vi.unstubAllEnvs(); }); +const SYNC_RUN_REFUSAL = + 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + + 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; + const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; @@ -176,6 +181,17 @@ describe('apify-api-search', () => { }); }); + it.each(['run actor synchronously', 'run sync get dataset items'])( + 'does not offer the synchronous run endpoints for %j', + async (query) => { + const result = await callTool(apifyApiSearch, { query }); + + const { operations } = result.structuredContent as { operations: { path: string }[] }; + expect(operations.length).toBeGreaterThan(0); + expect(operations.map(({ path }) => path)).not.toContainEqual(expect.stringContaining('run-sync')); + }, + ); + it('says so when nothing matches', async () => { const result = await callTool(apifyApiSearch, { query: 'zebra' }); @@ -252,6 +268,25 @@ describe('apify-api-details', () => { } }); + it.each([ + { path: '/v2/actors/{actorId}/run-sync' }, + { path: 'acts/john~my-actor/run-sync-get-dataset-items' }, + { path: 'actors/abc/run-sync', method: 'POST' }, + ])('lists no operation on the synchronous run endpoint $path and says why, without the spec', async (args) => { + const result = await callTool(apifyApiDetails, args); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + + it("does not name call-actor, which runs an Actor, in the refusal of a task's synchronous run endpoint", async () => { + const result = await callTool(apifyApiDetails, { path: 'actor-tasks/abc/run-sync', method: 'GET' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + }); + describe('with a test-only rule in the published list', () => { /** Calls the tool while the rule is in the list, so the index the tool loads leaves out what it names. */ async function callDetailsWithRule(rule: ApiBlockRule, args: Record) { @@ -288,6 +323,12 @@ describe('apify-api-details', () => { }); }); + it('names call-actor in the refusal of a synchronous run endpoint only when the session has it', async () => { + const result = await callTool(apifyApiDetails, { path: 'actors/abc/run-sync' }, [HELPER_TOOLS.API_DETAILS]); + + expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + }); + it('names the search tool on a path not in the spec only when the session has it', async () => { const withSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }); const withoutSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }, [ @@ -312,17 +353,78 @@ describe('apify-api-read', () => { }); }); - it('says a GET can start a paid run and names call-actor only when the session has it', () => { + it('says the synchronous run endpoints are refused and names call-actor only when the session has it', () => { const withCallActor = apifyApiRead.buildDescription!(ALL_TOOLS_PRESENT); const withoutCallActor = apifyApiRead.buildDescription!(only(HELPER_TOOLS.API_READ)); - const paidRun = 'A GET can start a paid Actor run, as the synchronous run endpoints do.'; + const refused = 'The synchronous run endpoints are refused'; expect(apifyApiRead.description).toBe(withCallActor); - expect(withCallActor).toContain(`${paidRun} Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); - expect(withoutCallActor).toContain(paidRun); + expect(withCallActor).toContain(`/v2/actor-runs/abc. Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); + expect(withCallActor).toContain(refused); + expect(withoutCallActor).toContain(refused); expect(withoutCallActor).not.toContain(HELPER_TOOLS.ACTOR_CALL); + expect(withCallActor).not.toContain('A GET can start'); + expect(withCallActor).toContain(`keep waitForFinish at or below ${WAIT_SECS_MAX} seconds.`); }); + it.each([ + 'actors/apify~hello-world/run-sync', + 'actors/~my-actor/run-sync', + 'actors/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', + 'acts/apify~hello-world/run-sync', + '/v2/acts/abc/run-sync-get-dataset-items?token=x', + 'v2/actors/abc/run-sync', + '/v2/actors/abc/run-sync', + // A slash encoded in a name is decoded after routing, so the name is one segment. + 'actors/apify%2Fhello-world/run-sync', + 'ACTORS/APIFY~HELLO-WORLD/RUN-SYNC-GET-DATASET-ITEMS', + 'actors//abc/run-sync', + 'actors/abc//run-sync', + 'actors/abc/run-sync//', + 'actors/abc/run-sync?build=latest', + // The URL is resolved before it is sent: dot segments, a backslash, and a newline. + 'actors/abc/x/../run-sync', + 'actors/abc/%2e%2e/abc/run-sync', + 'actors\\abc\\run-sync', + 'actors/abc/run-\nsync', + ])('refuses the synchronous run endpoint %j without a request', async (path) => { + const result = await callTool(apifyApiRead, { path }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it.each([ + 'actor-tasks/john~my-task/run-sync', + 'actor-tasks/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', + 'Actor-Tasks/john~my-task/Run-Sync/', + ])("refuses a task's synchronous run endpoint %j without a request or naming call-actor", async (path) => { + const result = await callTool(apifyApiRead, { path }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('names call-actor in the refusal of a synchronous run only when the session has it', async () => { + const result = await callTool(apifyApiRead, { path: 'actors/abc/run-sync' }, [HELPER_TOOLS.API_READ]); + + expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + }); + + it.each(['actors/abc/runs', 'actors/abc/runs/last', 'key-value-stores/abc/records/run-sync', 'actor-runs/abc'])( + 'sends a GET to %j, which does not start a run', + async (path) => { + requestMock.mockResolvedValue(mockResponse(200, { data: {} })); + + await callTool(apifyApiRead, { path }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(readSentUrl()).toBe(`${BASE_URL}/${path}`); + }, + ); + it.each([ { path: 'datasets/abc', query: { method: 'DELETE' } }, { path: 'datasets/abc', query: { METHOD: 'delete' } }, @@ -540,6 +642,17 @@ describe('apify-api-read', () => { }); }); + it('does not name a synchronous run endpoint among the closest paths of a 404', async () => { + requestMock.mockResolvedValue( + mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), + ); + + const error = await callTool(apifyApiRead, { path: 'actors/abc/run-syncs' }).catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ statusCode: 404, message: expect.stringContaining('The closest paths') }); + expect((error as Error).message).not.toContain('run-sync'); + }); + it.each([ ['record-not-found', 'actor-runs/abc', 'Actor run was not found.'], ['record-or-token-not-found', 'actors/abc', 'Actor was not found.'], @@ -777,6 +890,37 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it.each([ + { path: 'actors/apify~hello-world/run-sync', method: 'POST', body: {} }, + { path: 'acts/abc/run-sync-get-dataset-items', method: 'POST', body: {} }, + { path: 'actors//abc/RUN-SYNC/', method: 'POST', body: {} }, + ])('refuses the synchronous run endpoint $path without a request or the spec', async (args) => { + const result = await callTool(apifyApiWrite, args); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); + expect(requestMock).not.toHaveBeenCalled(); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + + it("refuses a task's synchronous run endpoint without a request, the spec, or naming call-actor", async () => { + const result = await callTool(apifyApiWrite, { path: 'actor-tasks/abc/run-sync-get-dataset-items', body: {} }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + expect(requestMock).not.toHaveBeenCalled(); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + + it('sends a POST to the asynchronous run endpoint', async () => { + requestMock.mockResolvedValue(mockResponse(201, { data: { id: 'run-1' } })); + + await callTool(apifyApiWrite, { path: 'actors/apify~hello-world/runs', method: 'POST', body: {} }); + + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })); + expect(readSentUrl()).toBe(`${BASE_URL}/actors/apify~hello-world/runs`); + }); + it.each([ { path: 'datasets/abc', method: 'PUT', query: { method: 'DELETE' }, body: { name: 'x' } }, { path: 'datasets/abc', method: 'PUT', query: { '[METHOD]': 'DELETE' }, body: { name: 'x' } }, diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 9f2d2b90a..f82751b40 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -3,10 +3,15 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { validateApiBlocklist, validateApiPathBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { normalizeApiPath } from '../../src/tools/api/apify_api_request.js'; const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; +const SYNC_RUN_REFUSAL = + 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + + 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; + /** A test-only operation rule. */ const OPERATION_RULE: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/actors/{actorId}/run-now' }, @@ -158,9 +163,94 @@ describe('validateApiBlocklist()', () => { expect(validate({ normalizedPath }, [rule])).toBe('No run-now.'); }); }); + + describe('the synchronous run rules', () => { + it.each([ + 'actors/apify~hello-world/run-sync', + 'actors/~my-actor/run-sync-get-dataset-items', + 'actors/HG7ML7M8z78YcAPEB/run-sync', + 'acts/apify~hello-world/run-sync', + 'acts/apify~hello-world/run-sync-get-dataset-items', + 'actor-tasks/john~my-task/run-sync', + 'actor-tasks/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', + // A slash encoded in a name is decoded after routing, so the name is one segment. + 'actors/apify%2Fhello-world/run-sync', + 'ACTORS/APIFY~HELLO-WORLD/RUN-SYNC', + 'Actor-Tasks/abc/Run-Sync-Get-Dataset-Items', + 'actors/abc/run-sync/', + 'actors//abc//run-sync', + 'actors/abc/run-sync?timeout=300&token=x', + normalizeApiPath('/v2/actors/abc/run-sync'), + normalizeApiPath('v2/actor-tasks/abc/run-sync'), + ])('refuses a GET to %j in the read tool and a POST in the write tool', (normalizedPath) => { + expect(validate({ normalizedPath })).toBe(SYNC_RUN_REFUSAL); + expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBe( + SYNC_RUN_REFUSAL, + ); + }); + + it.each([ + 'actors/abc/runs', + 'actors/abc/runs/last', + 'actor-tasks/abc/runs', + 'key-value-stores/abc/records/run-sync', + 'actor-runs/abc', + 'actor-runs/abc/run-sync', + 'actors/abc/run-sync-x', + 'actors/run-sync', + 'actors/apify/hello-world/run-sync', + 'datasets/run-sync', + ])('does not refuse %j', (normalizedPath) => { + expect(validate({ normalizedPath })).toBeUndefined(); + expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBeUndefined(); + }); + + it('refuses the write tool without a method, before it chooses one', () => { + expect( + validate({ + normalizedPath: 'actors/abc/run-sync', + toolName: HELPER_TOOLS.API_WRITE, + method: undefined, + }), + ).toBe(SYNC_RUN_REFUSAL); + }); + + it('names call-actor only for an Actor and only when the session has it', () => { + const loadedToolNames = [HELPER_TOOLS.ACTOR_CALL]; + const withCallActor = `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`; + + expect(validate({ normalizedPath: 'actors/abc/run-sync', loadedToolNames })).toBe(withCallActor); + expect(validate({ normalizedPath: 'acts/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( + withCallActor, + ); + expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames })).toBe(SYNC_RUN_REFUSAL); + expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( + SYNC_RUN_REFUSAL, + ); + }); + }); }); describe('validateApiPathBlocklist()', () => { + it.each([ + { + normalizedPath: 'actors/abc/run-sync', + refusal: `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, + }, + { normalizedPath: 'actor-tasks/{actorTaskId}/run-sync-get-dataset-items', refusal: SYNC_RUN_REFUSAL }, + ])('refuses $normalizedPath, a path a rule refuses in any tool', ({ normalizedPath, refusal }) => { + expect(validateApiPathBlocklist({ normalizedPath, loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] })).toBe(refusal); + }); + + it('refuses only the methods a rule names when a method is given', () => { + const normalizedPath = 'actors/abc/run-sync'; + + expect(validateApiPathBlocklist({ normalizedPath, method: 'POST', loadedToolNames: [] })).toBe( + SYNC_RUN_REFUSAL, + ); + expect(validateApiPathBlocklist({ normalizedPath, method: 'PUT', loadedToolNames: [] })).toBeUndefined(); + }); + it('checks only the operation rules', () => { expect(validateApiPathBlocklist({ normalizedPath: 'actors/abc/runs', loadedToolNames: [] })).toBeUndefined(); expect( diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index f7e623877..bbc250e2e 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -17,7 +17,29 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(38); + expect(index.size).toBe(35); + }); + + it('leaves out the synchronous run operations of Actors and tasks', () => { + expect(index.has('actor_runSync_get')).toBe(false); + expect(index.has('actor_runSync_post')).toBe(false); + expect(index.has('actor_runSyncGetDatasetItems_post')).toBe(false); + const taskSpec = { + paths: { + '/v2/actor-tasks/{actorTaskId}/run-sync': { + get: { operationId: 'actorTask_runSync_get' }, + post: { operationId: 'actorTask_runSync_post' }, + }, + '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items': { + post: { operationId: 'actorTask_runSyncGetDatasetItems_post' }, + }, + '/v2/actor-tasks/{actorTaskId}/runs': { post: { operationId: 'actorTask_runs_post' } }, + }, + }; + + expect([...buildApiOperationIndex(taskSpec).keys()]).toEqual(['actorTask_runs_post']); + // The blocklist leaves them out; without its rules, the index has them. + expect(buildApiOperationIndex(taskSpec, []).size).toBe(4); }); it('leaves out the operations a rule names', () => { @@ -139,8 +161,8 @@ describe('searchApiOperations()', () => { } it('matches word prefixes and ignores stop words', () => { - // Without the stop words, "with" would also match "Run Actor synchronously without input". - expect(searchIds('webhook with')).toEqual(['webhooks_get', 'webhooks_post', 'actor_webhooks_get']); + // Without the stop words, "in" would also match "Store items in last run's dataset". + expect(searchIds('webhook in')).toEqual(['webhooks_get', 'webhooks_post', 'actor_webhooks_get']); }); it('matches a plural to its singular and drops one-letter terms', () => { @@ -154,12 +176,11 @@ describe('searchApiOperations()', () => { expect(searchIds('rename')).toEqual([]); }); - it("ranks a storage's own operation above a run's copy of it and above a synchronous run", () => { - // Both competitors name the dataset in their summary; the storage's own operation does not. + it("ranks a storage's own operation above a run's copy of it", () => { + // The run's copy names the dataset in its summary; the storage's own operation does not. const ids = searchIds('add items to a dataset'); expect(ids[0]).toBe('dataset_items_post'); expect(ids.indexOf('actor_runs_last_dataset_items_post')).toBeGreaterThan(0); - expect(ids.indexOf('actor_runSyncGetDatasetItems_post')).toBeGreaterThan(0); }); it("keeps a run's copy first when the query is about runs", () => { From 0ee6b7db0e0fb1468dc0a1dcebdbff9f71bc3e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:16:20 +0200 Subject: [PATCH 058/103] fix: Annotate apify-api-read as read-only and idempotent With the method query parameter and the synchronous run endpoints refused, a GET from apify-api-read neither writes nor starts a run. Set readOnlyHint and idempotentHint to true, and say read-only in the README and AGENTS.md. Part of #1502 --- README.md | 2 +- src/tools/AGENTS.md | 27 ++++++++++++++------------- src/tools/api/apify_api_read.ts | 7 ++++--- tests/unit/tools.apify_api.test.ts | 7 ++++--- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0e1b3d2b3..c134ce007 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. It refuses the synchronous run endpoints and the `method` query parameter. | | +| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses the synchronous run endpoints and the `method` query parameter. | | | `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to an Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the synchronous run endpoints and the `method` query parameter. | | > **Note:** diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 22ea46df2..a00750566 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -47,19 +47,20 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the session has it. A query parameter rule matches a parameter by name in the call tools it names. An operation rule matches methods and a spec path template, and each method is refused in the tool that - sends it (GET: the read tool; any other: the write tool). The rules: the `method` query parameter, - which the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the - method it names; and the synchronous run endpoints of Actors and tasks (GET and POST), since a run - there is paid and can outlast the tool-call timeout. `callApi` checks every call against the list - (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, - decoded segment by segment, with a trailing or doubled slash, and `acts` read as `actors`; a query - name decoded, in any letter case, and with brackets. The write tool checks before it chooses a method - too; without a method, an operation rule with a method the write tool sends refuses the path. - `applyApiBlocklist` leaves out of the index each operation a rule names and each query parameter a - rule refuses to the tool that sends the operation's method. So search, details, the closest-path - hints, and the write tool's method choice never offer them. Details checks the path, and the method - when given, before it looks the path up (`validateApiPathBlocklist`), so it refuses with the rule's - reason and never answers with a path next to a refused one. + sends it (GET: the read tool; any other: the write tool). The rules: the `method` query parameter, which + the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the method + it names; and the synchronous run endpoints of Actors and tasks (GET and POST), since a run there is + paid and can outlast the tool-call timeout. So the read tool neither writes nor starts a run, and is + annotated read-only. `callApi` checks every call against the list (`validateApiBlocklist`) as the API + reads the request URL, and fail-closed: a path in any letter case, decoded segment by segment, with a + trailing or doubled slash, and `acts` read as `actors`; a query name decoded, in any letter case, and + with brackets. The write tool checks before it chooses a method too; without a method, an operation + rule with a method the write tool sends refuses the path. `applyApiBlocklist` leaves out of the index + each operation a rule names and each query parameter a rule refuses to the tool that sends the + operation's method. So search, details, the closest-path hints, and the write tool's method choice + never offer them. Details checks the path, and the method when given, before it looks the path up + (`validateApiPathBlocklist`), so it refuses with the rule's reason and never answers with a path next + to a refused one. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 83b59091c..8d0ca0da9 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -47,10 +47,11 @@ export const apifyApiRead: ToolEntry = Object.freeze({ ajvValidate: compileSchema(z.toJSONSchema(apifyApiReadArgs)), annotations: { title: 'Read Apify API', - // A GET can start a run, such as a synchronous run of an Actor or task. - readOnlyHint: false, + // The blocklist refuses the GETs that would write or start a run: the method query parameter and the + // synchronous run endpoints. + readOnlyHint: true, destructiveHint: false, - idempotentHint: false, + idempotentHint: true, openWorldHint: false, }, // A storage signature would otherwise be logged. diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 391b18646..8002e67b2 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -345,11 +345,12 @@ describe('apify-api-details', () => { }); describe('apify-api-read', () => { - it('is annotated as not read-only, since a GET can start a run', () => { + it('is annotated as read-only and idempotent, since it refuses the GETs that start a run or write', () => { expect(apifyApiRead.annotations).toMatchObject({ - readOnlyHint: false, + readOnlyHint: true, destructiveHint: false, - idempotentHint: false, + idempotentHint: true, + openWorldHint: false, }); }); From 9992af933f9359f03915bb45a2d7a645a26af655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:25:22 +0200 Subject: [PATCH 059/103] test: Run an operation rule through apify-api-read and apify-api-write The operation rule tests checked validateApiBlocklist, the index, and apify-api-details only, so callApi or the write tool's check before it chooses a method could pass the path without normalizeApiPath and no test would fail. Move the details helper to module scope as callToolWithRule, and add a test for each call tool with a test-only rule and a path written with the /v2 prefix: the read tool refuses without a request, and the write tool refuses before it loads the spec. --- tests/unit/tools.apify_api.test.ts | 52 ++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 5e18f6dcd..e17293694 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -66,6 +66,17 @@ async function callTool(tool: unknown, args: Record, loadedTool return (await (tool as HelperTool).call(context)) as TextToolResult & { toolTelemetry?: ToolTelemetrySnapshot }; } +/** Calls a tool while a test-only rule is in the published list, so the tool and the index it loads apply it. */ +async function callToolWithRule(tool: unknown, rule: ApiBlockRule, args: Record) { + const rules = API_BLOCK_RULES as ApiBlockRule[]; + rules.push(rule); + try { + return await callTool(tool, args); + } finally { + rules.pop(); + } +} + beforeEach(() => { // The tools check every URL against the configured API origin; pin it so the shell's // APIFY_API_BASE_URL does not fail the tests. @@ -253,21 +264,10 @@ describe('apify-api-details', () => { }); describe('with a test-only rule in the published list', () => { - /** Calls the tool while the rule is in the list, so the index the tool loads leaves out what it names. */ - async function callDetailsWithRule(rule: ApiBlockRule, args: Record) { - const rules = API_BLOCK_RULES as ApiBlockRule[]; - rules.push(rule); - try { - return await callTool(apifyApiDetails, args); - } finally { - rules.pop(); - } - } - it('refuses a literal path a rule names, not the parameter path next to it', async () => { const rule: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/users/me' }, reason: 'No.' }; - const result = await callDetailsWithRule(rule, { path: 'users/me' }); + const result = await callToolWithRule(apifyApiDetails, rule, { path: 'users/me' }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe('No.'); @@ -279,8 +279,8 @@ describe('apify-api-details', () => { reason: 'No.', }; - const refused = await callDetailsWithRule(rule, { path: 'datasets/abc', method: 'GET' }); - const allowed = await callDetailsWithRule(rule, { path: 'datasets/abc', method: 'PUT' }); + const refused = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'GET' }); + const allowed = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'PUT' }); expectSoftFailInvalidInput(refused); expect(refused.content[0].text).toBe('No.'); @@ -357,6 +357,16 @@ describe('apify-api-read', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('refuses a path a test-only operation rule names, written with the /v2 prefix, without a request', async () => { + const rule: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; + + const result = await callToolWithRule(apifyApiRead, rule, { path: '/v2/datasets/abc' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe('No.'); + expect(requestMock).not.toHaveBeenCalled(); + }); + it('sends a query parameter whose name only starts with method', async () => { requestMock.mockResolvedValue(mockResponse(200, { data: {} })); @@ -791,6 +801,20 @@ describe('apify-api-write', () => { expect(fetchApiOperationIndex).not.toHaveBeenCalled(); }); + it('refuses a path a test-only operation rule names before it chooses a method, without a request or the spec', async () => { + const rule: ApiBlockRule = { + operation: { methods: ['DELETE'], path: '/v2/datasets/{datasetId}' }, + reason: 'No.', + }; + + const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe('No.'); + expect(requestMock).not.toHaveBeenCalled(); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); From 6fd5eb52da743774e8a3269f1cea8ab09dc8ee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:36:54 +0200 Subject: [PATCH 060/103] fix: Refuse only the GET of the run-sync endpoints in apify-api-read A GET to an Actor's or a task's run-sync or run-sync-get-dataset-items endpoint starts a paid run, as a POST does, so apify-api-read refuses it. The POST stays allowed through apify-api-write, which a session gets only by naming it. Narrow the four run-sync rules to GET. The index keeps the POST operations, so search, details, the closest-path hints, and the write tool's method choice offer them again; with the GET left out, the write tool infers POST for these paths. Restore the search ranking of the run-sync paths and the shared synchronous-run advice in the call tools' description. apify-api-read's description says the GET is refused. The refusal says the GET starts a paid run and names call-actor for an Actor and apify-api-write for the POST, each only when the session has it, so a rule can now suggest several tools. Details drops the refused methods from its lookup instead of refusing the whole path: it lists the POST of a run-sync endpoint and still refuses a path with no method left. apify-api-read stays annotated read-only and idempotent. Part of #1502 --- README.md | 4 +- src/tools/AGENTS.md | 17 ++- src/tools/api/apify_api_blocklist.ts | 40 ++--- src/tools/api/apify_api_details.ts | 19 ++- src/tools/api/apify_api_read.ts | 3 +- src/tools/api/apify_api_request.ts | 4 +- src/tools/api/apify_api_spec.ts | 10 +- tests/unit/helpers/apify_api_spec_fixture.ts | 5 +- tests/unit/tools.apify_api.test.ts | 151 +++++++++++-------- tests/unit/tools.apify_api_blocklist.test.ts | 57 ++++--- tests/unit/tools.apify_api_spec.test.ts | 48 +++--- 11 files changed, 205 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index c134ce007..29e98f6e0 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,8 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses the synchronous run endpoints and the `method` query parameter. | | -| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to an Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the synchronous run endpoints and the `method` query parameter. | | +| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses a GET to the synchronous run endpoints, which starts a paid run, and the `method` query parameter. | | +| `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | > **Note:** > diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index a00750566..dff3572c4 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -44,23 +44,24 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI. They refuse what the blocklist in `apify_api_blocklist.ts` lists (`API_BLOCK_RULES`). Each rule names - what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the - session has it. A query parameter rule matches a parameter by name in the call tools it names. An + what it matches, the reason shown to the agent, and optionally tools to suggest, each named only when + the session has it. A query parameter rule matches a parameter by name in the call tools it names. An operation rule matches methods and a spec path template, and each method is refused in the tool that sends it (GET: the read tool; any other: the write tool). The rules: the `method` query parameter, which the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the method - it names; and the synchronous run endpoints of Actors and tasks (GET and POST), since a run there is - paid and can outlast the tool-call timeout. So the read tool neither writes nor starts a run, and is - annotated read-only. `callApi` checks every call against the list (`validateApiBlocklist`) as the API + it names; and a GET to the synchronous run endpoints of Actors and tasks, which starts a paid run as a + POST does (the write tool still sends the POST). So the read tool neither writes nor starts a run, and + is annotated read-only. `callApi` checks every call against the list (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, decoded segment by segment, with a trailing or doubled slash, and `acts` read as `actors`; a query name decoded, in any letter case, and with brackets. The write tool checks before it chooses a method too; without a method, an operation rule with a method the write tool sends refuses the path. `applyApiBlocklist` leaves out of the index each operation a rule names and each query parameter a rule refuses to the tool that sends the operation's method. So search, details, the closest-path hints, and the write tool's method choice - never offer them. Details checks the path, and the method when given, before it looks the path up - (`validateApiPathBlocklist`), so it refuses with the rule's reason and never answers with a path next - to a refused one. + never offer them. Details checks the method, when given, before it looks the path up, and drops from + the lookup each method refused on the path (`validateApiPathBlocklist`), so it refuses with the rule's + reason, lists only the POST of a synchronous run endpoint, and never answers with a path next to a + refused one. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 854f17946..2751c27c1 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -8,8 +8,8 @@ export type ApiCallToolName = typeof HELPER_TOOLS.API_READ | typeof HELPER_TOOLS type ApiBlockRuleBase = { /** Why the call is refused, shown to the agent. */ reason: string; - /** A tool to call instead, named only when the session has it and it is not the refusing tool. */ - suggestedToolName?: HelperToolName; + /** Tools to call instead, each named only when the session has it and it is not the refusing tool. */ + suggestedToolNames?: readonly HelperToolName[]; }; /** Matches a query parameter, in `query` or in a query string written into the path, by name. */ @@ -41,9 +41,8 @@ const TASK_SYNC_RUN_PATHS = [ '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', ] as const; -const SYNC_RUN_REASON = - 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + - 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; +const SYNC_RUN_GET_REASON = + 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; /** What the API tools refuse to send. The operation index leaves the same out, so search and details never offer it. */ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ @@ -54,20 +53,24 @@ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ reason: 'The API tools do not send the method query parameter: the API would take it as the HTTP method ' + 'of the request.', - suggestedToolName: HELPER_TOOLS.API_WRITE, + suggestedToolNames: [HELPER_TOOLS.API_WRITE], }, - // A GET or a POST there starts a paid run, which can outlast the tool-call timeout. The API takes no other - // method there but HEAD, which the tools never send. See apify/apify-mcp-server#1502. + // A GET there starts a paid run, as a POST does, so the read tool refuses it to stay read-only. The POST stays + // with the write tool, which a session gets only by naming it. See apify/apify-mcp-server#1502. ...ACTOR_SYNC_RUN_PATHS.map( (path): ApiOperationBlockRule => ({ - operation: { methods: ['GET', 'POST'], path }, - reason: SYNC_RUN_REASON, - suggestedToolName: HELPER_TOOLS.ACTOR_CALL, + operation: { methods: ['GET'], path }, + reason: SYNC_RUN_GET_REASON, + suggestedToolNames: [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE], }), ), - // call-actor runs an Actor, not a saved task, so these suggest no tool. + // call-actor runs an Actor, not a saved task, so these suggest only the write tool, for the POST. ...TASK_SYNC_RUN_PATHS.map( - (path): ApiOperationBlockRule => ({ operation: { methods: ['GET', 'POST'], path }, reason: SYNC_RUN_REASON }), + (path): ApiOperationBlockRule => ({ + operation: { methods: ['GET'], path }, + reason: SYNC_RUN_GET_REASON, + suggestedToolNames: [HELPER_TOOLS.API_WRITE], + }), ), ]; @@ -137,17 +140,14 @@ function parseRequestUrl(normalizedPath: string): URL { return new URL(`https://api.invalid/v2/${normalizedPath}`); } -/** The reason of a rule, with the tool it suggests when the session has it and it is not the refusing tool. */ +/** The reason of a rule, with each tool it suggests that the session has and that is not the refusing tool. */ function formatRefusal( - { reason, suggestedToolName }: ApiBlockRule, + { reason, suggestedToolNames = [] }: ApiBlockRule, toolName: ApiCallToolName | undefined, loadedToolNames: readonly string[], ): string { - const canSuggest = - suggestedToolName !== undefined && - suggestedToolName !== toolName && - loadedToolNames.includes(suggestedToolName); - return canSuggest ? `${reason} Use ${suggestedToolName} instead.` : reason; + const suggested = suggestedToolNames.filter((name) => name !== toolName && loadedToolNames.includes(name)); + return suggested.length > 0 ? `${reason} Use ${suggested.join(' or ')} instead.` : reason; } /** diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index a54020e11..a958c0248 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -15,6 +15,7 @@ import { normalizeApiPath, redactApiCallArgs, } from './apify_api_request.js'; +import type { ApiMethod } from './apify_api_spec.js'; import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ @@ -71,18 +72,20 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); const normalizedPath = normalizeApiPath(parsed.path); - // Before the lookup: the index leaves out the operations the call tools refuse, so the lookup could - // match a path next to a refused one or say a refused method is missing. - const refusal = validateApiPathBlocklist({ - normalizedPath, - method: parsed.method, - loadedToolNames: toolArgs.loadedToolNames, - }); + const findRefusal = (method?: ApiMethod) => + validateApiPathBlocklist({ normalizedPath, method, loadedToolNames: toolArgs.loadedToolNames }); + // Before the lookup: the index leaves out the operations the call tools refuse, so the lookup would say + // a refused method is missing. + const refusal = parsed.method && findRefusal(parsed.method); if (refusal) return respondUserError(refusal); const index = await fetchApiOperationIndex(); const path = formatApiPath(normalizedPath); - const matched = findPathOperations(index, normalizedPath); + // Without the methods refused on the path: with a refused operation left out, the lookup can match the + // path next to it. + const matched = findPathOperations(index, normalizedPath).filter((operation) => !findRefusal(operation.method)); if (matched.length === 0) { + const pathRefusal = findRefusal(); + if (pathRefusal) return respondUserError(pathRefusal); const next = toolArgs.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` : ''; diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 8d0ca0da9..365989dd0 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -17,7 +17,8 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { : ''; const runActor = hasTool(HELPER_TOOLS.ACTOR_CALL) ? ` Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.` : ''; return dedent` - Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc.${runActor} + Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc. + A GET to the synchronous run endpoints is refused: it starts a paid run.${runActor} ${API_CALL_DESCRIPTION}${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 86dd5a629..188bbd3ae 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -36,8 +36,8 @@ export const apiCallArgsShape = { export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. Write the path as actors, v2/actors, or /v2/actors, with its values in it. A name is written username~name, or ~name for one you own, as in /v2/actors/~my-actor. -The synchronous run endpoints are refused: a run can outlast the usual 60-second tool-call timeout. -For the same reason, keep waitForFinish at or below ${WAIT_SECS_MAX} seconds.`; +Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, +so prefer asynchronous runs.`; /** Query parameters whose values grant access or carry secrets; `webhooks` can hold webhook headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index c9bbc6744..4c3b32feb 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -147,7 +147,7 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, and anything * outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. So are the * operations and query parameters a rule refuses to the tool that sends the method (`applyApiBlocklist`), - * such as the synchronous run endpoints, so no tool that reads the index offers them. + * such as the `method` query parameter, so no tool that reads the index offers them. * It throws when the spec lists no operation. */ export function buildApiOperationIndex( @@ -284,11 +284,11 @@ function hasWholeWord(term: string, words: string[]): boolean { /** * Paths that reach a run's own copy of a resource: the default storages and actions of a run or of the - * last run. A plain ask such as "add items to a dataset" means the storage itself, so these rank lower - * unless the query is about runs or tasks. + * last run, and the synchronous runs. A plain ask such as "add items to a dataset" means the storage + * itself, so these rank lower unless the query is about runs or tasks. */ -const RUN_SCOPED_PATH_REGEX = /^\/v2\/actor-runs\/\{runId\}\/|\/runs\/last\//; -const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'task', 'tasks']); +const RUN_SCOPED_PATH_REGEX = /^\/v2\/actor-runs\/\{runId\}\/|\/runs\/last\/|\/run-sync/; +const RUN_SCOPE_TERMS: ReadonlySet = new Set(['run', 'runs', 'last', 'task', 'tasks', 'sync']); const RUN_SCOPED_PENALTY = 2; /** diff --git a/tests/unit/helpers/apify_api_spec_fixture.ts b/tests/unit/helpers/apify_api_spec_fixture.ts index e51aba953..2971728e9 100644 --- a/tests/unit/helpers/apify_api_spec_fixture.ts +++ b/tests/unit/helpers/apify_api_spec_fixture.ts @@ -159,8 +159,8 @@ export const API_SPEC_FIXTURE = { parameters: [{ $ref: '#/components/parameters/actorId' }], }, }, - // A run's copy of the dataset operation, which names the dataset in its summary: search must - // still rank the storage's own operation first. + // A run's copy of the dataset operation, and a synchronous run, both of which name the dataset + // in their summary: search must still rank the storage's own operation first. '/v2/actors/{actorId}/runs/last/dataset/items': { post: { operationId: 'actor_runs_last_dataset_items_post', @@ -170,7 +170,6 @@ export const API_SPEC_FIXTURE = { requestBody: { required: true, content: { 'application/json': { schema: { type: 'array' } } } }, }, }, - // A synchronous run, which the index leaves out. '/v2/actors/{actorId}/run-sync-get-dataset-items': { post: { operationId: 'actor_runSyncGetDatasetItems_post', diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 8b2502d2c..1f828d892 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApifyClient } from '../../src/apify_client.js'; import { HELPER_TOOLS, MAX_INLINE_BYTES } from '../../src/const.js'; -import { WAIT_SECS_MAX } from '../../src/tools/actors/actor_run_response.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { API_BLOCK_RULES } from '../../src/tools/api/apify_api_blocklist.js'; import { apifyApiDetails } from '../../src/tools/api/apify_api_details.js'; @@ -91,8 +90,7 @@ afterEach(() => { }); const SYNC_RUN_REFUSAL = - 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + - 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; + 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; @@ -193,13 +191,14 @@ describe('apify-api-search', () => { }); it.each(['run actor synchronously', 'run sync get dataset items'])( - 'does not offer the synchronous run endpoints for %j', + 'offers only the POST of the synchronous run endpoints for %j', async (query) => { const result = await callTool(apifyApiSearch, { query }); - const { operations } = result.structuredContent as { operations: { path: string }[] }; - expect(operations.length).toBeGreaterThan(0); - expect(operations.map(({ path }) => path)).not.toContainEqual(expect.stringContaining('run-sync')); + const { operations } = result.structuredContent as { operations: { method: string; path: string }[] }; + const syncRuns = operations.filter(({ path }) => path.includes('run-sync')); + expect(syncRuns.length).toBeGreaterThan(0); + expect(new Set(syncRuns.map(({ method }) => method))).toEqual(new Set(['POST'])); }, ); @@ -279,23 +278,38 @@ describe('apify-api-details', () => { } }); + it.each(['/v2/actors/{actorId}/run-sync', 'acts/john~my-actor/run-sync-get-dataset-items', 'actors/abc/run-sync'])( + 'refuses the GET of the synchronous run endpoint %s and says why, without the spec', + async (path) => { + const result = await callTool(apifyApiDetails, { path, method: 'GET' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE} instead.`, + ); + expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + }, + ); + it.each([ - { path: '/v2/actors/{actorId}/run-sync' }, - { path: 'acts/john~my-actor/run-sync-get-dataset-items' }, - { path: 'actors/abc/run-sync', method: 'POST' }, - ])('lists no operation on the synchronous run endpoint $path and says why, without the spec', async (args) => { + [{ path: '/v2/actors/{actorId}/run-sync' }, '/v2/actors/{actorId}/run-sync: POST.'], + [{ path: 'acts/john~my-actor/run-sync' }, '/v2/actors/{actorId}/run-sync: POST.'], + [ + { path: 'actors/abc/run-sync-get-dataset-items', method: 'POST' }, + '/v2/actors/{actorId}/run-sync-get-dataset-items: POST.', + ], + ])('returns only the POST of the synchronous run endpoint for %j', async (args, summary) => { const result = await callTool(apifyApiDetails, args); - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); - expect(fetchApiOperationIndex).not.toHaveBeenCalled(); + expectSchemaConformingStructuredContent(result, apifyApiDetailsOutputSchema); + expect(result.content[1].text).toBe(summary); }); - it("does not name call-actor, which runs an Actor, in the refusal of a task's synchronous run endpoint", async () => { + it("names the write tool, not call-actor, which runs an Actor, in the refusal of a task's synchronous run GET", async () => { const result = await callTool(apifyApiDetails, { path: 'actor-tasks/abc/run-sync', method: 'GET' }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`); }); describe('with a test-only rule in the published list', () => { @@ -316,15 +330,19 @@ describe('apify-api-details', () => { const refused = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'GET' }); const allowed = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'PUT' }); + const withoutMethod = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc' }); expectSoftFailInvalidInput(refused); expect(refused.content[0].text).toBe('No.'); expect(allowed.content[1].text).toBe('/v2/datasets/{datasetId}: PUT.'); + expect(withoutMethod.content[1].text).toBe('/v2/datasets/{datasetId}: PUT, DELETE.'); }); }); - it('names call-actor in the refusal of a synchronous run endpoint only when the session has it', async () => { - const result = await callTool(apifyApiDetails, { path: 'actors/abc/run-sync' }, [HELPER_TOOLS.API_DETAILS]); + it('names call-actor and the write tool in the refusal of a synchronous run GET only when the session has them', async () => { + const result = await callTool(apifyApiDetails, { path: 'actors/abc/run-sync', method: 'GET' }, [ + HELPER_TOOLS.API_DETAILS, + ]); expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); }); @@ -354,18 +372,15 @@ describe('apify-api-read', () => { }); }); - it('says the synchronous run endpoints are refused and names call-actor only when the session has it', () => { + it('says a GET to the synchronous run endpoints is refused and names call-actor only when the session has it', () => { const withCallActor = apifyApiRead.buildDescription!(ALL_TOOLS_PRESENT); const withoutCallActor = apifyApiRead.buildDescription!(only(HELPER_TOOLS.API_READ)); - const refused = 'The synchronous run endpoints are refused'; + const refused = 'A GET to the synchronous run endpoints is refused: it starts a paid run.'; expect(apifyApiRead.description).toBe(withCallActor); - expect(withCallActor).toContain(`/v2/actor-runs/abc. Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); - expect(withCallActor).toContain(refused); + expect(withCallActor).toContain(`${refused} Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); expect(withoutCallActor).toContain(refused); expect(withoutCallActor).not.toContain(HELPER_TOOLS.ACTOR_CALL); - expect(withCallActor).not.toContain('A GET can start'); - expect(withCallActor).toContain(`keep waitForFinish at or below ${WAIT_SECS_MAX} seconds.`); }); it.each([ @@ -388,11 +403,13 @@ describe('apify-api-read', () => { 'actors/abc/%2e%2e/abc/run-sync', 'actors\\abc\\run-sync', 'actors/abc/run-\nsync', - ])('refuses the synchronous run endpoint %j without a request', async (path) => { + ])('refuses a GET to the synchronous run endpoint %j without a request', async (path) => { const result = await callTool(apifyApiRead, { path }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); + expect(result.content[0].text).toBe( + `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE} instead.`, + ); expect(requestMock).not.toHaveBeenCalled(); }); @@ -400,19 +417,36 @@ describe('apify-api-read', () => { 'actor-tasks/john~my-task/run-sync', 'actor-tasks/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', 'Actor-Tasks/john~my-task/Run-Sync/', - ])("refuses a task's synchronous run endpoint %j without a request or naming call-actor", async (path) => { + ])("refuses a GET to a task's synchronous run endpoint %j without a request or naming call-actor", async (path) => { const result = await callTool(apifyApiRead, { path }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); + expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`); expect(requestMock).not.toHaveBeenCalled(); }); - it('names call-actor in the refusal of a synchronous run only when the session has it', async () => { - const result = await callTool(apifyApiRead, { path: 'actors/abc/run-sync' }, [HELPER_TOOLS.API_READ]); + it.each([ + ['actors/abc/run-sync', [HELPER_TOOLS.API_READ], SYNC_RUN_REFUSAL], + [ + 'actors/abc/run-sync', + [HELPER_TOOLS.API_READ, HELPER_TOOLS.ACTOR_CALL], + `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, + ], + [ + 'actors/abc/run-sync', + [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], + `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`, + ], + ['actor-tasks/abc/run-sync', [HELPER_TOOLS.API_READ, HELPER_TOOLS.ACTOR_CALL], SYNC_RUN_REFUSAL], + ])( + 'names call-actor and the write tool in the refusal of a GET to %s only when the session has them (%j)', + async (path, loadedToolNames, refusal) => { + const result = await callTool(apifyApiRead, { path }, loadedToolNames); - expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); - }); + expect(result.content[0].text).toBe(refusal); + expect(requestMock).not.toHaveBeenCalled(); + }, + ); it.each(['actors/abc/runs', 'actors/abc/runs/last', 'key-value-stores/abc/records/run-sync', 'actor-runs/abc'])( 'sends a GET to %j, which does not start a run', @@ -653,15 +687,18 @@ describe('apify-api-read', () => { }); }); - it('does not name a synchronous run endpoint among the closest paths of a 404', async () => { + it('names the POST template of a synchronous run endpoint among the closest paths of a 404', async () => { requestMock.mockResolvedValue( mockResponse(404, { error: { type: 'page-not-found', message: 'Page not found.' } }), ); const error = await callTool(apifyApiRead, { path: 'actors/abc/run-syncs' }).catch((thrown: unknown) => thrown); - expect(error).toMatchObject({ statusCode: 404, message: expect.stringContaining('The closest paths') }); - expect((error as Error).message).not.toContain('run-sync'); + expect(error).toMatchObject({ + statusCode: 404, + message: expect.stringContaining('The closest paths in the API spec: '), + }); + expect((error as Error).message).toContain('/v2/actors/{actorId}/run-sync'); }); it.each([ @@ -836,6 +873,12 @@ describe('apify-api-write', () => { }); }); + it('advises an asynchronous run without saying a synchronous run is refused, since it sends the POST', () => { + expect(apifyApiWrite.description).toContain('Synchronous runs, and waitForFinish above'); + expect(apifyApiWrite.description).toContain('so prefer asynchronous runs.'); + expect(apifyApiWrite.description).not.toContain('refused'); + }); + it('sends one request with the body serialized as JSON and returns the response', async () => { const body = { data: { id: 'abc', name: 'leads-2026' } }; requestMock.mockResolvedValue(mockResponse(200, body)); @@ -902,34 +945,22 @@ describe('apify-api-write', () => { }); it.each([ - { path: 'actors/apify~hello-world/run-sync', method: 'POST', body: {} }, - { path: 'acts/abc/run-sync-get-dataset-items', method: 'POST', body: {} }, - { path: 'actors//abc/RUN-SYNC/', method: 'POST', body: {} }, - ])('refuses the synchronous run endpoint $path without a request or the spec', async (args) => { - const result = await callTool(apifyApiWrite, args); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`); - expect(requestMock).not.toHaveBeenCalled(); - expect(fetchApiOperationIndex).not.toHaveBeenCalled(); - }); - - it("refuses a task's synchronous run endpoint without a request, the spec, or naming call-actor", async () => { - const result = await callTool(apifyApiWrite, { path: 'actor-tasks/abc/run-sync-get-dataset-items', body: {} }); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); - expect(requestMock).not.toHaveBeenCalled(); - expect(fetchApiOperationIndex).not.toHaveBeenCalled(); - }); - - it('sends a POST to the asynchronous run endpoint', async () => { + { path: 'actors/apify~hello-world/run-sync', method: 'POST' }, + { path: 'acts/abc/run-sync-get-dataset-items', method: 'POST' }, + { path: 'actors//abc/RUN-SYNC/', method: 'POST' }, + { path: 'actor-tasks/john~my-task/run-sync', method: 'POST' }, + { path: 'actor-tasks/abc/run-sync-get-dataset-items', method: 'POST' }, + // Without a method: the index leaves out the GET, so the POST is the path's only method. + { path: 'actors/apify~hello-world/run-sync' }, + { path: 'acts/abc/run-sync-get-dataset-items' }, + ])('sends a POST to the synchronous run endpoint for %j', async (args) => { requestMock.mockResolvedValue(mockResponse(201, { data: { id: 'run-1' } })); - await callTool(apifyApiWrite, { path: 'actors/apify~hello-world/runs', method: 'POST', body: {} }); + await callTool(apifyApiWrite, { ...args, body: {} }); - expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })); - expect(readSentUrl()).toBe(`${BASE_URL}/actors/apify~hello-world/runs`); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', data: '{}' })); + expect(readSentUrl()).toBe(`${BASE_URL}/${args.path}`); }); it.each([ diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index f82751b40..73ba1daf3 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -9,8 +9,7 @@ const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; const SYNC_RUN_REFUSAL = - 'The API tools do not call the synchronous run endpoints, whose runs can outlast the tool-call timeout. ' + - 'Start an asynchronous run with POST /v2/actors/{actorId}/runs or /v2/actor-tasks/{actorTaskId}/runs.'; + 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; /** A test-only operation rule. */ const OPERATION_RULE: ApiBlockRule = { @@ -154,7 +153,7 @@ describe('validateApiBlocklist()', () => { }); it('names the suggested tool only when the session has it', () => { - const rule: ApiBlockRule = { ...OPERATION_RULE, suggestedToolName: HELPER_TOOLS.ACTOR_CALL }; + const rule: ApiBlockRule = { ...OPERATION_RULE, suggestedToolNames: [HELPER_TOOLS.ACTOR_CALL] }; const normalizedPath = 'actors/abc/run-now'; expect(validate({ normalizedPath, loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] }, [rule])).toBe( @@ -182,11 +181,9 @@ describe('validateApiBlocklist()', () => { 'actors/abc/run-sync?timeout=300&token=x', normalizeApiPath('/v2/actors/abc/run-sync'), normalizeApiPath('v2/actor-tasks/abc/run-sync'), - ])('refuses a GET to %j in the read tool and a POST in the write tool', (normalizedPath) => { + ])('refuses a GET to %j in the read tool, not a POST in the write tool', (normalizedPath) => { expect(validate({ normalizedPath })).toBe(SYNC_RUN_REFUSAL); - expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBe( - SYNC_RUN_REFUSAL, - ); + expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBeUndefined(); }); it.each([ @@ -205,28 +202,46 @@ describe('validateApiBlocklist()', () => { expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBeUndefined(); }); - it('refuses the write tool without a method, before it chooses one', () => { + it('does not refuse the write tool without a method, since it never sends the GET', () => { expect( validate({ normalizedPath: 'actors/abc/run-sync', toolName: HELPER_TOOLS.API_WRITE, method: undefined, }), - ).toBe(SYNC_RUN_REFUSAL); + ).toBeUndefined(); }); - it('names call-actor only for an Actor and only when the session has it', () => { - const loadedToolNames = [HELPER_TOOLS.ACTOR_CALL]; - const withCallActor = `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`; + it.each([ + [ + [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE], + `Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE}`, + ], + [[HELPER_TOOLS.ACTOR_CALL], `Use ${HELPER_TOOLS.ACTOR_CALL}`], + [[HELPER_TOOLS.API_WRITE], `Use ${HELPER_TOOLS.API_WRITE}`], + ])( + 'names for an Actor call-actor and the write tool, each only when the session has it (%j)', + (loadedToolNames, use) => { + expect(validate({ normalizedPath: 'actors/abc/run-sync', loadedToolNames })).toBe( + `${SYNC_RUN_REFUSAL} ${use} instead.`, + ); + expect(validate({ normalizedPath: 'acts/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( + `${SYNC_RUN_REFUSAL} ${use} instead.`, + ); + }, + ); - expect(validate({ normalizedPath: 'actors/abc/run-sync', loadedToolNames })).toBe(withCallActor); - expect(validate({ normalizedPath: 'acts/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( - withCallActor, - ); - expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames })).toBe(SYNC_RUN_REFUSAL); + it('names for a task only the write tool, since call-actor cannot run a saved task', () => { + const loadedToolNames = [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE]; + const withWriteTool = `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`; + + expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames })).toBe(withWriteTool); expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( - SYNC_RUN_REFUSAL, + withWriteTool, ); + expect( + validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] }), + ).toBe(SYNC_RUN_REFUSAL); }); }); }); @@ -245,10 +260,8 @@ describe('validateApiPathBlocklist()', () => { it('refuses only the methods a rule names when a method is given', () => { const normalizedPath = 'actors/abc/run-sync'; - expect(validateApiPathBlocklist({ normalizedPath, method: 'POST', loadedToolNames: [] })).toBe( - SYNC_RUN_REFUSAL, - ); - expect(validateApiPathBlocklist({ normalizedPath, method: 'PUT', loadedToolNames: [] })).toBeUndefined(); + expect(validateApiPathBlocklist({ normalizedPath, method: 'GET', loadedToolNames: [] })).toBe(SYNC_RUN_REFUSAL); + expect(validateApiPathBlocklist({ normalizedPath, method: 'POST', loadedToolNames: [] })).toBeUndefined(); }); it('checks only the operation rules', () => { diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index bbc250e2e..d1e188858 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -17,29 +17,32 @@ describe('buildApiOperationIndex()', () => { expect(index.has('dataset_items_head')).toBe(false); expect(index.has('outside_get')).toBe(false); expect([...index.values()].every((operation) => operation.operationId)).toBe(true); - expect(index.size).toBe(35); + expect(index.size).toBe(37); }); - it('leaves out the synchronous run operations of Actors and tasks', () => { + it('leaves out the GET of the synchronous run endpoints of Actors and tasks, and keeps the POST', () => { expect(index.has('actor_runSync_get')).toBe(false); - expect(index.has('actor_runSync_post')).toBe(false); - expect(index.has('actor_runSyncGetDatasetItems_post')).toBe(false); - const taskSpec = { - paths: { - '/v2/actor-tasks/{actorTaskId}/run-sync': { - get: { operationId: 'actorTask_runSync_get' }, - post: { operationId: 'actorTask_runSync_post' }, - }, - '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items': { - post: { operationId: 'actorTask_runSyncGetDatasetItems_post' }, - }, - '/v2/actor-tasks/{actorTaskId}/runs': { post: { operationId: 'actorTask_runs_post' } }, - }, + expect(index.has('actor_runSync_post')).toBe(true); + expect(index.has('actor_runSyncGetDatasetItems_post')).toBe(true); + const syncRunSpec = { + paths: Object.fromEntries( + [ + '/v2/actors/{actorId}/run-sync', + '/v2/actors/{actorId}/run-sync-get-dataset-items', + '/v2/actor-tasks/{actorTaskId}/run-sync', + '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + ].map((path) => [path, { get: { operationId: `GET ${path}` }, post: { operationId: `POST ${path}` } }]), + ), }; - expect([...buildApiOperationIndex(taskSpec).keys()]).toEqual(['actorTask_runs_post']); - // The blocklist leaves them out; without its rules, the index has them. - expect(buildApiOperationIndex(taskSpec, []).size).toBe(4); + expect([...buildApiOperationIndex(syncRunSpec).keys()]).toEqual([ + 'POST /v2/actors/{actorId}/run-sync', + 'POST /v2/actors/{actorId}/run-sync-get-dataset-items', + 'POST /v2/actor-tasks/{actorTaskId}/run-sync', + 'POST /v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + ]); + // The blocklist leaves the GETs out; without its rules, the index has them. + expect(buildApiOperationIndex(syncRunSpec, []).size).toBe(8); }); it('leaves out the operations a rule names', () => { @@ -161,8 +164,8 @@ describe('searchApiOperations()', () => { } it('matches word prefixes and ignores stop words', () => { - // Without the stop words, "in" would also match "Store items in last run's dataset". - expect(searchIds('webhook in')).toEqual(['webhooks_get', 'webhooks_post', 'actor_webhooks_get']); + // Without the stop words, "with" would also match "Run Actor synchronously with input and return output". + expect(searchIds('webhook with')).toEqual(['webhooks_get', 'webhooks_post', 'actor_webhooks_get']); }); it('matches a plural to its singular and drops one-letter terms', () => { @@ -176,11 +179,12 @@ describe('searchApiOperations()', () => { expect(searchIds('rename')).toEqual([]); }); - it("ranks a storage's own operation above a run's copy of it", () => { - // The run's copy names the dataset in its summary; the storage's own operation does not. + it("ranks a storage's own operation above a run's copy of it and above a synchronous run", () => { + // Both competitors name the dataset in their summary; the storage's own operation does not. const ids = searchIds('add items to a dataset'); expect(ids[0]).toBe('dataset_items_post'); expect(ids.indexOf('actor_runs_last_dataset_items_post')).toBeGreaterThan(0); + expect(ids.indexOf('actor_runSyncGetDatasetItems_post')).toBeGreaterThan(0); }); it("keeps a run's copy first when the query is about runs", () => { From cb4e093a33631d660535f8b3e3f3cd3d9e0bfea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:50:10 +0200 Subject: [PATCH 061/103] fix: Refuse in apify-api-details only the method given on a path the lookup misses A POST to a synchronous run endpoint with a trailing or doubled slash, or in another letter case, got the refusal of its GET, which the write tool does not apply to a POST. With a method given, the path is now reported as not in the spec. --- src/tools/api/apify_api_details.ts | 2 +- tests/unit/tools.apify_api.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index a958c0248..c6a324527 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -84,7 +84,7 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ // path next to it. const matched = findPathOperations(index, normalizedPath).filter((operation) => !findRefusal(operation.method)); if (matched.length === 0) { - const pathRefusal = findRefusal(); + const pathRefusal = findRefusal(parsed.method); if (pathRefusal) return respondUserError(pathRefusal); const next = toolArgs.loadedToolNames.includes(HELPER_TOOLS.API_SEARCH) ? ` Find the path with ${HELPER_TOOLS.API_SEARCH}.` diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 1f828d892..39c5ab8a2 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -305,6 +305,16 @@ describe('apify-api-details', () => { expect(result.content[1].text).toBe(summary); }); + it.each(['actors/abc/run-sync/', 'actors//abc/run-sync', 'actors/abc/RUN-SYNC'])( + 'says the POST of %s, a synchronous run endpoint the lookup misses, is not in the spec, not that its GET is refused', + async (path) => { + const result = await callTool(apifyApiDetails, { path, method: 'POST' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain(`The path /v2/${path} is not in the API spec.`); + }, + ); + it("names the write tool, not call-actor, which runs an Actor, in the refusal of a task's synchronous run GET", async () => { const result = await callTool(apifyApiDetails, { path: 'actor-tasks/abc/run-sync', method: 'GET' }); From 99454894c70f3426099650c1ef764330f659a3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:50:26 +0200 Subject: [PATCH 062/103] docs: Say apify-api-details refuses a path only when the lookup has no method left Details drops from the lookup the methods refused on the path, not the other methods of a neighbouring template, so it can answer with a path next to one whose operations a rule all refuses. No shipped rule does so. --- src/tools/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index dff3572c4..42c1d7c08 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -60,8 +60,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. operation's method. So search, details, the closest-path hints, and the write tool's method choice never offer them. Details checks the method, when given, before it looks the path up, and drops from the lookup each method refused on the path (`validateApiPathBlocklist`), so it refuses with the rule's - reason, lists only the POST of a synchronous run endpoint, and never answers with a path next to a - refused one. + reason, lists only the POST of a synchronous run endpoint, and, without a method, refuses the path + when the lookup has no method left. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a From f7fec242df44803d1185d5b9362edbd859216229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:50:26 +0200 Subject: [PATCH 063/103] docs: Restore why apify-api-read redacts the webhooks of a synchronous run Arguments are logged before the blocklist refuses a GET to a synchronous run endpoint, so its webhooks query value is still redacted. --- src/tools/api/apify_api_read.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 365989dd0..2f2ec559c 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -55,7 +55,7 @@ export const apifyApiRead: ToolEntry = Object.freeze({ idempotentHint: true, openWorldHint: false, }, - // A storage signature would otherwise be logged. + // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiReadArgs.parse(toolArgs.args); From 5465ec232de7b342f9be06f802bc55e22b66836b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:50:41 +0200 Subject: [PATCH 064/103] test: Name the run-sync path blocklist case for the GET rule it checks --- tests/unit/tools.apify_api_blocklist.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 73ba1daf3..dc2668f4b 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -253,7 +253,7 @@ describe('validateApiPathBlocklist()', () => { refusal: `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, }, { normalizedPath: 'actor-tasks/{actorTaskId}/run-sync-get-dataset-items', refusal: SYNC_RUN_REFUSAL }, - ])('refuses $normalizedPath, a path a rule refuses in any tool', ({ normalizedPath, refusal }) => { + ])('refuses $normalizedPath without a method, since a rule refuses its GET', ({ normalizedPath, refusal }) => { expect(validateApiPathBlocklist({ normalizedPath, loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] })).toBe(refusal); }); From 052b22e25a99bd5d837ea3a16c4650c02a6cef82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 13:50:41 +0200 Subject: [PATCH 065/103] docs: Reword the apify-api-read refusals in the README tool table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 29e98f6e0..059335f87 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Legend for the **Enabled by default** column: | `get-actor-version` | source | Get an Actor version's file listing with hashes and the content of the files you ask for. | | | `apify-api-search` | api | Search the Apify API reference for operations by keywords. Returns each one's method, path, and docs page. | | | `apify-api-details` | api | Get the parameters and request body schema of the Apify API operations on a path. | | -| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses a GET to the synchronous run endpoints, which starts a paid run, and the `method` query parameter. | | +| `apify-api-read` | api | Send a GET request to an Apify API path, such as `actor-runs` or `/v2/actor-runs`, like `apify api` in the Apify CLI. Read-only: it refuses the `method` query parameter and a GET to the synchronous run endpoints, which starts a paid run. | | | `apify-api-write` | none (select by name: `tools=apify-api-write`) | Send a POST, PUT, PATCH, or DELETE request to any Apify API path, with a JSON body, like `apify api` in the Apify CLI. It refuses the `method` query parameter. | | > **Note:** From 6e892efa72ce31676a915b0057c5ca5c3e9f5256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 14:57:29 +0200 Subject: [PATCH 066/103] refactor: Simplify the API blocklist A rule now matches either a query parameter, refused in every call, or one operation (a method and a spec path template), refused in the tool that sends that method. Two functions apply the list: validateApiBlocklist, which callApi runs on every call, and isApiOperationBlocked, which the operation index uses to leave an operation out. Removed: validateApiPathBlocklist and the details tool's own refusal (details reads the filtered index, as search does), the write tool's check before it chooses a method (callApi checks the call), the filtering of query parameters out of the index (the spec declares no method parameter), per-tool rule targets, and getApiCallToolName. --- src/tools/AGENTS.md | 26 +-- src/tools/api/apify_api_blocklist.ts | 201 +++++-------------- src/tools/api/apify_api_details.ts | 11 +- src/tools/api/apify_api_request.ts | 7 +- src/tools/api/apify_api_spec.ts | 42 ++-- src/tools/api/apify_api_write.ts | 11 - tests/unit/tools.apify_api.test.ts | 63 +++--- tests/unit/tools.apify_api_blocklist.test.ts | 146 +++++++------- tests/unit/tools.apify_api_spec.test.ts | 53 +---- 9 files changed, 194 insertions(+), 366 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 80bbc4019..7aeabd961 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -43,22 +43,16 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The write tool is in no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI. - They refuse what the blocklist in `apify_api_blocklist.ts` lists (`API_BLOCK_RULES`). Each rule names - what it matches, the reason shown to the agent, and optionally a tool to suggest, named only when the - session has it. A query parameter rule matches a parameter by name in the call tools it names. An - operation rule matches methods and a spec path template, and each method is refused in the tool that - sends it (GET: the read tool; any other: the write tool). The one rule is the `method` query parameter, - which the API takes as the HTTP method, so the read tool sends only a GET and the write tool only the - method it names. `callApi` checks every call against the list (`validateApiBlocklist`) as the API - reads the request URL, and fail-closed: a path in any letter case, decoded segment by segment, with a - trailing or doubled slash, and `acts` read as `actors`; a query name decoded, in any letter case, and - with brackets. The write tool checks before it chooses a method too; without a method, an operation - rule with a method the write tool sends refuses the path. `applyApiBlocklist` leaves out of the index - each operation a rule names and each query parameter a rule refuses to the tool that sends the - operation's method. So search, details, the closest-path hints, and the write tool's method choice - never offer them. Details checks the path, and the method when given, before it looks the path up - (`validateApiPathBlocklist`), so it refuses with the rule's reason and never answers with a path next - to a refused one. + They refuse what `API_BLOCK_RULES` in `apify_api_blocklist.ts` lists. A rule matches either a query + parameter, refused in every call, or one operation (a method and a spec path template), refused in the + tool that sends that method. It gives the reason shown to the agent and optionally tools to suggest, + each named only when the session has it and it is not the refusing tool. The one rule is the `method` + query parameter, which the API takes as the HTTP method. `callApi` checks every call + (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, + decoded segment by segment, with trailing or doubled slashes, and `acts` read as `actors`; a query + name decoded, in any letter case, and with brackets. The index leaves out each operation a rule + matches (`isApiOperationBlocked`), so search, details, the closest-path hints, and the write tool's + method choice never offer it. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index fb4182420..26f0c23d1 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -1,82 +1,47 @@ import type { HelperToolName } from '../../const.js'; import { HELPER_TOOLS } from '../../const.js'; -import type { ApiMethod, ApiParameter } from './apify_api_spec.js'; +import type { ApiMethod } from './apify_api_spec.js'; -/** The API tools that send requests, the ones a rule refuses calls in. */ -export type ApiCallToolName = typeof HELPER_TOOLS.API_READ | typeof HELPER_TOOLS.API_WRITE; - -type ApiBlockRuleBase = { +/** A request the API tools refuse to send. */ +export type ApiBlockRule = { + /** A query parameter, refused in every call, or one operation: a method and a spec path template. */ + match: { queryParam: string } | { method: ApiMethod; path: `/v2/${string}` }; /** Why the call is refused, shown to the agent. */ reason: string; - /** A tool to call instead, named only when the session has it and it is not the refusing tool. */ - suggestedToolName?: HelperToolName; -}; - -/** Matches a query parameter, in `query` or in a query string written into the path, by name. */ -export type ApiQueryParamBlockRule = ApiBlockRuleBase & { - queryParam: string; - /** The tools that refuse a call the rule matches. */ - toolNames: readonly ApiCallToolName[]; + /** Tools to use instead, each named only when the session has it and it is not the refusing tool. */ + suggestedToolNames?: readonly HelperToolName[]; }; -/** - * Matches operations: HTTP methods and a path template as the spec writes it, such as `/v2/actors/{actorId}`. - * Each method is refused in the tool that sends it (`getApiCallToolName`). - */ -export type ApiOperationBlockRule = ApiBlockRuleBase & { - operation: { methods: readonly ApiMethod[]; path: `/v2/${string}` }; -}; - -export type ApiBlockRule = ApiQueryParamBlockRule | ApiOperationBlockRule; - -/** What the API tools refuse to send. The operation index leaves the same out, so search and details never offer it. */ +/** What the API tools refuse to send. The operation index leaves out each operation a rule matches. */ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ { // A GET with method=DELETE deletes, so the read tool could write; see apify/apify-mcp-server#1501. - queryParam: 'method', - toolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], + match: { queryParam: 'method' }, reason: 'The API tools do not send the method query parameter: the API would take it as the HTTP method ' + 'of the request.', - suggestedToolName: HELPER_TOOLS.API_WRITE, + suggestedToolNames: [HELPER_TOOLS.API_WRITE], }, ]; -/** The tool that sends a method: the read tool sends only a GET, and the write tool every other method. */ -export function getApiCallToolName(method: ApiMethod): ApiCallToolName { - return method === 'GET' ? HELPER_TOOLS.API_READ : HELPER_TOOLS.API_WRITE; -} - -/** - * A text with each `%XX` escape decoded to that byte as a character. Unlike `decodeURIComponent`, it never - * throws, so a malformed escape elsewhere in the text cannot hide a name. - */ +/** Decodes each `%XX` escape. Unlike `decodeURIComponent`, it never throws, so a malformed escape hides nothing. */ function decodeEscapes(text: string): string { return text.replace(/%([0-9a-f]{2})/gi, (_escape, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); } /** - * The top-level name the API's query parser (qs, as Express runs it) reads from a query parameter name, or a - * broader one: decoded, in lowercase, and without brackets, since `method[]`, `method[0]`, and `[method]` all - * reach the API as `method`. + * A query parameter name as the API's query parser (qs, run by Express) reads it, decoded and in lowercase: + * qs reads `method[]`, `method[0]`, and `[method]` all as `method`. */ -function extractQueryParamRoot(name: string): string { +function extractQueryParamName(name: string): string { const decodedName = decodeEscapes(name).toLowerCase(); return /^\[([^[\]]*)\]/.exec(decodedName)?.[1] ?? decodedName.split('[')[0]; } -function isBlockedQueryParam(name: string, rule: ApiQueryParamBlockRule): boolean { - return extractQueryParamRoot(name) === rule.queryParam.toLowerCase(); -} - -/** A path template segment that is a parameter, such as `{actorId}`. */ -export const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; - /** - * The segments of a path as the API routes it: split first and then decoded one by one, so an escaped slash - * stays in its segment; in lowercase; without the empty segments of a trailing slash, or of a doubled one in - * case anything before the router merges it; and with the legacy `acts` prefix read as `actors`, the one the - * spec lists. + * A path's segments as the API's router compares them: in lowercase, without the empty segments of a trailing + * or doubled slash, and with the legacy `acts` prefix read as `actors`. Split before decoding, as the router + * does, so the escaped slash in `apify%2Fhello-world` stays in its segment. */ function splitRoutePath(path: string): string[] { const segments = path @@ -87,121 +52,51 @@ function splitRoutePath(path: string): string[] { return segments; } -/** - * Whether the API routes a path, with values or as a spec template, to the operation of the rule. Without a - * method, the path alone decides. - */ -function isBlockedOperation(method: ApiMethod | undefined, path: string, rule: ApiOperationBlockRule): boolean { - if (method !== undefined && !rule.operation.methods.includes(method)) return false; - const templateSegments = splitRoutePath(rule.operation.path); +/** Whether an operation rule matches a method and a path, with values or a spec template. */ +function isOperationMatch(match: ApiBlockRule['match'], method: ApiMethod, path: string): boolean { + if (!('path' in match) || match.method !== method) return false; const segments = splitRoutePath(path); - if (segments.length !== templateSegments.length) return false; - // A parameter matches one segment, never an empty one: the split drops those. - return templateSegments.every( - (templateSegment, position) => - PATH_PARAMETER_SEGMENT_REGEX.test(templateSegment) || segments[position] === templateSegment, + const templateSegments = splitRoutePath(match.path); + return ( + segments.length === templateSegments.length && + templateSegments.every((segment, position) => segment.startsWith('{') || segment === segments[position]) ); } -/** A normalized path parsed as the request URL is. Any origin will do; only the path and the query are checked. */ -function parseRequestUrl(normalizedPath: string): URL { - return new URL(`https://api.invalid/v2/${normalizedPath}`); -} - -/** The reason of a rule, with the tool it suggests when the session has it and it is not the refusing tool. */ -function formatRefusal( - { reason, suggestedToolName }: ApiBlockRule, - toolName: ApiCallToolName | undefined, - loadedToolNames: readonly string[], -): string { - const canSuggest = - suggestedToolName !== undefined && - suggestedToolName !== toolName && - loadedToolNames.includes(suggestedToolName); - return canSuggest ? `${reason} Use ${suggestedToolName} instead.` : reason; -} - /** - * Why a rule refuses a call, or `undefined` when none does. The path is parsed as the request URL is: dot - * segments resolved, a backslash read as a slash, tabs and newlines dropped. The match is fail-closed: a - * path matches in any letter case and with a trailing or doubled slash, and a query name matches decoded, in - * any letter case, and with brackets. Without a method (the write tool checks before it chooses one), an - * operation rule with a method the tool sends refuses the path; `callApi` checks again with the method. + * Why a rule refuses a request, or `undefined` when none does. It matches fail-closed: a path in any letter + * case, decoded segment by segment, and with trailing or doubled slashes; a query parameter name decoded, in + * any letter case, and with brackets. */ export function validateApiBlocklist( - { - toolName, - method, - normalizedPath, - query, - loadedToolNames, - }: { - toolName: ApiCallToolName; - method: ApiMethod | undefined; + request: { + method: ApiMethod; /** The path as `normalizeApiPath` returns it, with any query string written into it. */ normalizedPath: string; query?: Record; loadedToolNames: readonly string[]; }, - rules: readonly ApiBlockRule[] = API_BLOCK_RULES, + rules = API_BLOCK_RULES, ): string | undefined { - const { pathname, searchParams } = parseRequestUrl(normalizedPath); - const queryNames = [...searchParams.keys(), ...Object.keys(query ?? {})]; - const rule = rules.find((candidate) => - 'queryParam' in candidate - ? candidate.toolNames.includes(toolName) && queryNames.some((name) => isBlockedQueryParam(name, candidate)) - : candidate.operation.methods.some((ruleMethod) => getApiCallToolName(ruleMethod) === toolName) && - isBlockedOperation(method, pathname, candidate), + const { method, normalizedPath, query = {}, loadedToolNames } = request; + // Parsed as the request URL is: dot segments resolved, a backslash read as a slash, tabs and newlines dropped. + const { pathname, searchParams } = new URL(`https://api.invalid/v2/${normalizedPath}`); + const queryNames = [...searchParams.keys(), ...Object.keys(query)].map(extractQueryParamName); + const rule = rules.find(({ match }) => + 'queryParam' in match + ? queryNames.includes(match.queryParam.toLowerCase()) + : isOperationMatch(match, method, pathname), ); - return rule && formatRefusal(rule, toolName, loadedToolNames); -} - -/** - * Why the API tools refuse calls to a path in a method, or `undefined` when no operation rule matches it; - * without a method, the path alone decides. For a tool that reads the index instead of calling, such as - * apify-api-details: the index leaves out what a rule refuses, so a lookup there could match a path next - * to it or say the method is missing. The path is parsed as in `validateApiBlocklist`. - */ -export function validateApiPathBlocklist( - { - normalizedPath, - method, - loadedToolNames, - }: { - /** The path as `normalizeApiPath` returns it. */ - normalizedPath: string; - method?: ApiMethod; - loadedToolNames: readonly string[]; - }, - rules: readonly ApiBlockRule[] = API_BLOCK_RULES, -): string | undefined { - const { pathname } = parseRequestUrl(normalizedPath); - const rule = rules.find((candidate) => 'operation' in candidate && isBlockedOperation(method, pathname, candidate)); - return rule && formatRefusal(rule, undefined, loadedToolNames); + if (!rule) return undefined; + // The read tool sends a GET, and the write tool every other method. + const refusingToolName = method === 'GET' ? HELPER_TOOLS.API_READ : HELPER_TOOLS.API_WRITE; + const toolNames = (rule.suggestedToolNames ?? []).filter( + (name) => name !== refusingToolName && loadedToolNames.includes(name), + ); + return toolNames.length > 0 ? `${rule.reason} Use ${toolNames.join(' or ')} instead.` : rule.reason; } -/** - * An operation of the spec as the API tools offer it: `undefined` when a rule refuses it to the tool that sends - * its method, and otherwise without the query parameters a rule refuses to that tool. - */ -export function applyApiBlocklist( - operation: TOperation, - rules: readonly ApiBlockRule[] = API_BLOCK_RULES, -): TOperation | undefined { - const isBlocked = rules.some( - (rule) => 'operation' in rule && isBlockedOperation(operation.method, operation.path, rule), - ); - if (isBlocked) return undefined; - const toolName = getApiCallToolName(operation.method); - const parameters = operation.parameters.filter( - (parameter) => - parameter.in !== 'query' || - !rules.some( - (rule) => - 'queryParam' in rule && - rule.toolNames.includes(toolName) && - isBlockedQueryParam(parameter.name, rule), - ), - ); - return { ...operation, parameters }; +/** Whether a rule matches an operation of the spec, given by its method and path template. */ +export function isApiOperationBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { + return rules.some(({ match }) => isOperationMatch(match, method, path)); } diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index a54020e11..130694eb4 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,7 +7,6 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; -import { validateApiPathBlocklist } from './apify_api_blocklist.js'; import { findPathOperations, formatApiPath, @@ -70,16 +69,8 @@ export const apifyApiDetails: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiDetailsArgs.parse(toolArgs.args); - const normalizedPath = normalizeApiPath(parsed.path); - // Before the lookup: the index leaves out the operations the call tools refuse, so the lookup could - // match a path next to a refused one or say a refused method is missing. - const refusal = validateApiPathBlocklist({ - normalizedPath, - method: parsed.method, - loadedToolNames: toolArgs.loadedToolNames, - }); - if (refusal) return respondUserError(refusal); const index = await fetchApiOperationIndex(); + const normalizedPath = normalizeApiPath(parsed.path); const path = formatApiPath(normalizedPath); const matched = findPathOperations(index, normalizedPath); if (matched.length === 0) { diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 188bbd3ae..c1f78faf0 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -12,7 +12,7 @@ import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_reso import type { ToolResponse } from '../../utils/mcp.js'; import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; -import { getApiCallToolName, PATH_PARAMETER_SEGMENT_REGEX, validateApiBlocklist } from './apify_api_blocklist.js'; +import { validateApiBlocklist } from './apify_api_blocklist.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -93,6 +93,8 @@ function toSpecPath(normalizedPath: string): string { return stripQuery(normalizedPath).replace(/^acts(?=\/|$)/, 'actors'); } +const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; + /** How many literal segments of the template match the path segments, or `undefined` when it does not match. */ function countMatchingLiterals(template: string, segments: string[]): number | undefined { const templateSegments = normalizeApiPath(template).split('/'); @@ -277,13 +279,12 @@ export async function callApi(params: { body?: unknown; /** Aborts the request when the client cancels the tool call. */ signal?: AbortSignal; - /** The session's tools, to name a dedicated log tool when a log is too large. */ + /** The session's tools, so a refusal or a too-large log names only tools the session has. */ loadedToolNames: readonly string[]; }): Promise { const { client, method } = params; const normalizedPath = normalizeApiPath(params.path); const refusal = validateApiBlocklist({ - toolName: getApiCallToolName(method), method, normalizedPath, query: params.query, diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index 4c3b32feb..ec84700d3 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { apifyApiOperationsCache } from '../../state.js'; import type { ApiBlockRule } from './apify_api_blocklist.js'; -import { API_BLOCK_RULES, applyApiBlocklist } from './apify_api_blocklist.js'; +import { isApiOperationBlocked } from './apify_api_blocklist.js'; export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; @@ -144,40 +144,32 @@ function parseRequestBody(rawRequestBody: unknown, spec: unknown): ApiOperation[ } /** - * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, and anything - * outside `/v2/` are left out; malformed entries are skipped rather than failing the whole spec. So are the - * operations and query parameters a rule refuses to the tool that sends the method (`applyApiBlocklist`), - * such as the `method` query parameter, so no tool that reads the index offers them. + * Builds the operation index from an OpenAPI document. Deprecated operations, HEAD operations, operations a + * blocklist rule matches (`API_BLOCK_RULES`), and anything outside `/v2/` are left out, so no tool that reads + * the index offers them; malformed entries are skipped rather than failing the whole spec. * It throws when the spec lists no operation. */ -export function buildApiOperationIndex( - spec: unknown, - rules: readonly ApiBlockRule[] = API_BLOCK_RULES, -): Map { +export function buildApiOperationIndex(spec: unknown, rules?: readonly ApiBlockRule[]): Map { const index = new Map(); const paths = isRecord(spec) && isRecord(spec.paths) ? spec.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; for (const method of API_METHODS) { const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); - if (!parsed.success || parsed.data.deprecated) continue; + if (!parsed.success || parsed.data.deprecated || isApiOperationBlocked(method, path, rules)) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); - const operation = applyApiBlocklist( - { - operationId, - method, - path, - summary: summary ?? '', - description: description ?? '', - docsUrl: `${API_DOCS_BASE_URL}/${splitWords(operationId).join('-')}`, - tags: tags ?? [], - parameters: parseParameters(parameters, spec), - ...(body && { requestBody: body }), - }, - rules, - ); - if (operation) index.set(operationId, operation); + index.set(operationId, { + operationId, + method, + path, + summary: summary ?? '', + description: description ?? '', + docsUrl: `${API_DOCS_BASE_URL}/${splitWords(operationId).join('-')}`, + tags: tags ?? [], + parameters: parseParameters(parameters, spec), + ...(body && { requestBody: body }), + }); } } if (index.size === 0) throw new Error('the spec lists no /v2/ operations.'); diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 0650fe38f..ee3017b26 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -7,7 +7,6 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; -import { validateApiBlocklist } from './apify_api_blocklist.js'; import { API_CALL_DESCRIPTION, apiCallArgsShape, @@ -121,16 +120,6 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ redactArgs: redactApiCallArgs, call: async (toolArgs: InternalToolArgs) => { const parsed = apifyApiWriteArgs.parse(toolArgs.args); - // Before the method is inferred: the spec leaves a refused path out, so inference would ask for a method. - // callApi checks again, with the method. - const refusal = validateApiBlocklist({ - toolName: HELPER_TOOLS.API_WRITE, - method: parsed.method, - normalizedPath: normalizeApiPath(parsed.path), - query: parsed.query, - loadedToolNames: toolArgs.loadedToolNames, - }); - if (refusal) return respondUserError(refusal); const body = parseBody(parsed.body); if ('error' in body) return respondUserError(body.error); const resolved = parsed.method diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index e17293694..81c8c1590 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -263,29 +263,19 @@ describe('apify-api-details', () => { } }); - describe('with a test-only rule in the published list', () => { - it('refuses a literal path a rule names, not the parameter path next to it', async () => { - const rule: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/users/me' }, reason: 'No.' }; + it('returns only the methods a test-only operation rule does not match', async () => { + const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; - const result = await callToolWithRule(apifyApiDetails, rule, { path: 'users/me' }); + const refused = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'GET' }); + const allowed = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'PUT' }); + const withoutMethod = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc' }); - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe('No.'); - }); - - it('refuses a method a rule names and returns the methods it does not name', async () => { - const rule: ApiBlockRule = { - operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, - reason: 'No.', - }; - - const refused = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'GET' }); - const allowed = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'PUT' }); - - expectSoftFailInvalidInput(refused); - expect(refused.content[0].text).toBe('No.'); - expect(allowed.content[1].text).toBe('/v2/datasets/{datasetId}: PUT.'); - }); + expectSoftFailInvalidInput(refused); + expect(refused.content[0].text).toBe( + 'The path /v2/datasets/abc has no GET operation; it matches methods PUT and DELETE.', + ); + expect(allowed.content[1].text).toBe('/v2/datasets/{datasetId}: PUT.'); + expect(withoutMethod.content[1].text).toBe('/v2/datasets/{datasetId}: PUT, DELETE.'); }); it('names the search tool on a path not in the spec only when the session has it', async () => { @@ -358,7 +348,7 @@ describe('apify-api-read', () => { }); it('refuses a path a test-only operation rule names, written with the /v2 prefix, without a request', async () => { - const rule: ApiBlockRule = { operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; + const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; const result = await callToolWithRule(apifyApiRead, rule, { path: '/v2/datasets/abc' }); @@ -790,7 +780,7 @@ describe('apify-api-write', () => { it.each([ { path: 'datasets/abc', method: 'PUT', query: { method: 'DELETE' }, body: { name: 'x' } }, { path: 'datasets/abc', method: 'PUT', query: { '[METHOD]': 'DELETE' }, body: { name: 'x' } }, - { path: 'datasets/abc?method=DELETE', body: { name: 'x' } }, + { path: 'datasets/abc?method=DELETE', method: 'PUT', body: { name: 'x' } }, { path: 'datasets/abc?%6Dethod%5B%5D=delete', method: 'PUT', body: { name: 'x' } }, ])('refuses the method query parameter in %j without a request or the spec', async (args) => { const result = await callTool(apifyApiWrite, args); @@ -801,13 +791,18 @@ describe('apify-api-write', () => { expect(fetchApiOperationIndex).not.toHaveBeenCalled(); }); - it('refuses a path a test-only operation rule names before it chooses a method, without a request or the spec', async () => { - const rule: ApiBlockRule = { - operation: { methods: ['DELETE'], path: '/v2/datasets/{datasetId}' }, - reason: 'No.', - }; + it('refuses the method query parameter after it chooses the method, without a request', async () => { + const result = await callTool(apifyApiWrite, { path: 'actor-runs/abc/abort?method=DELETE' }); - const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc' }); + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe(METHOD_PARAM_REFUSAL); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('refuses a method a test-only operation rule names, without a request or the spec', async () => { + const rule: ApiBlockRule = { match: { method: 'DELETE', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; + + const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc', method: 'DELETE' }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe('No.'); @@ -815,6 +810,16 @@ describe('apify-api-write', () => { expect(fetchApiOperationIndex).not.toHaveBeenCalled(); }); + it('asks only for a write method a test-only operation rule does not match', async () => { + const rule: ApiBlockRule = { match: { method: 'DELETE', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; + + const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toContain('The path matches method PUT; specify it to call the endpoint with.'); + expect(requestMock).not.toHaveBeenCalled(); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 9f2d2b90a..0a4826c51 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -2,26 +2,24 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; -import { validateApiBlocklist, validateApiPathBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { isApiOperationBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; /** A test-only operation rule. */ -const OPERATION_RULE: ApiBlockRule = { - operation: { methods: ['GET'], path: '/v2/actors/{actorId}/run-now' }, +const RUN_NOW_RULE: ApiBlockRule = { + match: { method: 'GET', path: '/v2/actors/{actorId}/run-now' }, reason: 'No run-now.', }; -/** Validates a GET from the read tool in a session with no other tools, unless the params say otherwise. */ +/** Validates a GET in a session with no tools, unless the params say otherwise. */ function validate( params: Partial[0]> & { normalizedPath: string }, rules?: readonly ApiBlockRule[], ): string | undefined { - return validateApiBlocklist( - { toolName: HELPER_TOOLS.API_READ, method: 'GET', loadedToolNames: [], ...params }, - rules, - ); + return validateApiBlocklist({ method: 'GET', loadedToolNames: [], ...params }, rules); } describe('validateApiBlocklist()', () => { @@ -44,9 +42,8 @@ describe('validateApiBlocklist()', () => { { normalizedPath: 'datasets/abc?[METHOD][x]=DELETE' }, // The URL drops a tab before it is sent. { normalizedPath: 'datasets/abc?me\tthod=DELETE' }, - ])('refuses %j in both call tools', (params) => { - expect(validate(params)).toBe(METHOD_PARAM_REFUSAL); - expect(validate({ ...params, toolName: HELPER_TOOLS.API_WRITE, method: 'PUT' })).toBe(METHOD_PARAM_REFUSAL); + ])('refuses %j in every method', (params) => { + for (const method of API_METHODS) expect(validate({ ...params, method })).toBe(METHOD_PARAM_REFUSAL); }); it.each([ @@ -59,32 +56,27 @@ describe('validateApiBlocklist()', () => { expect(validate(params)).toBeUndefined(); }); - it('checks the query names without the method, before the write tool chooses one', () => { - expect( - validate({ - toolName: HELPER_TOOLS.API_WRITE, - method: undefined, - normalizedPath: 'datasets/abc?method=PUT', - }), - ).toBe(METHOD_PARAM_REFUSAL); - }); - }); - - it('names the suggested tool only when the session has it and it is not the refusing tool', () => { - const params = { normalizedPath: 'datasets/abc', query: { method: 'DELETE' } }; - - expect(validate({ ...params, loadedToolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE] })).toBe( - `${METHOD_PARAM_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`, - ); - expect(validate({ ...params, loadedToolNames: [HELPER_TOOLS.API_READ] })).toBe(METHOD_PARAM_REFUSAL); - expect( - validate({ - ...params, - toolName: HELPER_TOOLS.API_WRITE, + it.each([ + { + method: 'GET', + loadedToolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], + refusal: `${METHOD_PARAM_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`, + }, + { method: 'GET', loadedToolNames: [HELPER_TOOLS.API_READ], refusal: METHOD_PARAM_REFUSAL }, + // The write tool sends the PUT, so it is the refusing tool. + { method: 'PUT', loadedToolNames: [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], - }), - ).toBe(METHOD_PARAM_REFUSAL); + refusal: METHOD_PARAM_REFUSAL, + }, + ] as const)( + 'names the write tool only when the session has it and it does not send $method ($loadedToolNames)', + ({ method, loadedToolNames, refusal }) => { + expect(validate({ normalizedPath: 'datasets/abc?method=DELETE', method, loadedToolNames })).toBe( + refusal, + ); + }, + ); }); describe('an operation rule', () => { @@ -109,8 +101,8 @@ describe('validateApiBlocklist()', () => { // Empty segments are ignored, in case anything before the router merges doubled slashes. 'actors//abc//run-now', 'actors/abc/run-now//', - ])('refuses the method and path template it names for %j', (normalizedPath) => { - expect(validate({ normalizedPath }, [OPERATION_RULE])).toBe('No run-now.'); + ])('refuses a GET to %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [RUN_NOW_RULE])).toBe('No run-now.'); }); it.each([ @@ -121,50 +113,62 @@ describe('validateApiBlocklist()', () => { 'actor-runs/abc/run-now', 'actsx/abc/run-now', 'actors/abc/run-now/x', - ])('does not refuse %j', (normalizedPath) => { - expect(validate({ normalizedPath }, [OPERATION_RULE])).toBeUndefined(); + ])('does not refuse a GET to %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [RUN_NOW_RULE])).toBeUndefined(); }); - it('refuses each method it names in the tool that sends that method, and no other method', () => { - const rule: ApiBlockRule = { - operation: { methods: ['GET', 'DELETE'], path: '/v2/actors/{actorId}/run-now' }, - reason: 'No run-now.', - }; - const normalizedPath = 'actors/abc/run-now'; - const write = { normalizedPath, toolName: HELPER_TOOLS.API_WRITE }; - - expect(validate({ normalizedPath }, [rule])).toBe('No run-now.'); - expect(validate({ ...write, method: 'DELETE' }, [rule])).toBe('No run-now.'); - expect(validate({ ...write, method: 'POST' }, [rule])).toBeUndefined(); - }); + it.each(['POST', 'PUT', 'PATCH', 'DELETE'] as const)( + 'does not refuse %s, a method it does not name', + (method) => { + expect(validate({ normalizedPath: 'actors/abc/run-now', method }, [RUN_NOW_RULE])).toBeUndefined(); + }, + ); - it('refuses the path in any method when the method is not chosen yet, in a tool that sends one it names', () => { - const normalizedPath = 'actors/abc/run-now'; + it('refuses a write method it names', () => { + const rule: ApiBlockRule = { match: { method: 'DELETE', path: '/v2/actors/{actorId}' }, reason: 'No.' }; - expect(validate({ normalizedPath, method: undefined }, [OPERATION_RULE])).toBe('No run-now.'); - // The write tool never sends the rule's GET. - expect( - validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: undefined }, [OPERATION_RULE]), - ).toBeUndefined(); + expect(validate({ normalizedPath: 'actors/abc', method: 'DELETE' }, [rule])).toBe('No.'); + expect(validate({ normalizedPath: 'actors/abc', method: 'GET' }, [rule])).toBeUndefined(); }); - it('names the suggested tool only when the session has it', () => { - const rule: ApiBlockRule = { ...OPERATION_RULE, suggestedToolName: HELPER_TOOLS.ACTOR_CALL }; - const normalizedPath = 'actors/abc/run-now'; + it.each([ + [ + [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE], + `No run-now. Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE} instead.`, + ], + [[HELPER_TOOLS.API_WRITE], `No run-now. Use ${HELPER_TOOLS.API_WRITE} instead.`], + [[], 'No run-now.'], + ])('names each suggested tool only when the session has it (%j)', (loadedToolNames, refusal) => { + const rule: ApiBlockRule = { + ...RUN_NOW_RULE, + suggestedToolNames: [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE], + }; - expect(validate({ normalizedPath, loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] }, [rule])).toBe( - `No run-now. Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, - ); - expect(validate({ normalizedPath }, [rule])).toBe('No run-now.'); + expect(validate({ normalizedPath: 'actors/abc/run-now', loadedToolNames }, [rule])).toBe(refusal); }); }); }); -describe('validateApiPathBlocklist()', () => { - it('checks only the operation rules', () => { - expect(validateApiPathBlocklist({ normalizedPath: 'actors/abc/runs', loadedToolNames: [] })).toBeUndefined(); - expect( - validateApiPathBlocklist({ normalizedPath: 'not-in-spec?method=DELETE', loadedToolNames: [] }), - ).toBeUndefined(); +describe('isApiOperationBlocked()', () => { + it.each([ + { method: 'GET', path: '/v2/actors/{actorId}/run-now', isBlocked: true }, + { method: 'POST', path: '/v2/actors/{actorId}/run-now', isBlocked: false }, + { method: 'GET', path: '/v2/actors/{actorId}/runs', isBlocked: false }, + { method: 'GET', path: '/v2/actors/{actorId}', isBlocked: false }, + ] as const)('returns $isBlocked for $method $path', ({ method, path, isBlocked }) => { + expect(isApiOperationBlocked(method, path, [RUN_NOW_RULE])).toBe(isBlocked); + }); + + it('matches a literal segment of a rule only to that literal, not to a parameter of the spec', () => { + const rules: ApiBlockRule[] = [{ match: { method: 'GET', path: '/v2/users/me' }, reason: 'No.' }]; + + expect(isApiOperationBlocked('GET', '/v2/users/me', rules)).toBe(true); + expect(isApiOperationBlocked('GET', '/v2/users/{userId}', rules)).toBe(false); + }); + + it('returns false for a query parameter rule', () => { + const rules: ApiBlockRule[] = [{ match: { queryParam: 'method' }, reason: 'No.' }]; + + expect(isApiOperationBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); }); }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index f7e623877..becaa672f 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { APIFY_API_OPENAPI_URL, @@ -20,12 +19,13 @@ describe('buildApiOperationIndex()', () => { expect(index.size).toBe(38); }); - it('leaves out the operations a rule names', () => { + it('leaves out the operations a rule matches', () => { const rules: ApiBlockRule[] = [ - { operation: { methods: ['GET'], path: '/v2/datasets/{datasetId}' }, reason: 'No.' }, - { operation: { methods: ['DELETE'], path: '/v2/actors/{actorId}' }, reason: 'No.' }, + { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }, + { match: { method: 'DELETE', path: '/v2/actors/{actorId}' }, reason: 'No.' }, // A literal segment matches only itself, not a parameter of the spec. - { operation: { methods: ['GET'], path: '/v2/users/me' }, reason: 'No.' }, + { match: { method: 'GET', path: '/v2/users/me' }, reason: 'No.' }, + { match: { queryParam: 'limit' }, reason: 'No.' }, ]; const blockedIndex = buildApiOperationIndex(API_SPEC_FIXTURE, rules); @@ -37,49 +37,6 @@ describe('buildApiOperationIndex()', () => { ]); }); - it('drops the method query parameter, which the API takes as the HTTP method, from every operation', () => { - const spec = { - paths: { - '/v2/things/{thingId}': { - get: { - operationId: 'thing_get', - parameters: [ - { name: 'thingId', in: 'path', required: true }, - { name: 'method', in: 'query' }, - { name: 'limit', in: 'query' }, - ], - }, - post: { operationId: 'thing_post', parameters: [{ name: 'Method[]', in: 'query' }] }, - }, - }, - }; - - const thingIndex = buildApiOperationIndex(spec); - - expect(thingIndex.get('thing_get')?.parameters.map(({ name }) => name)).toEqual(['thingId', 'limit']); - expect(thingIndex.get('thing_post')?.parameters).toEqual([]); - }); - - it('drops a query parameter only from the operations of the tools its rule names', () => { - const rules: ApiBlockRule[] = [ - { queryParam: 'limit', toolNames: [HELPER_TOOLS.API_WRITE], reason: 'Refused.' }, - ]; - const parameters = [{ name: 'limit', in: 'query' }]; - const spec = { - paths: { - '/v2/things': { - get: { operationId: 'things_get', parameters }, - post: { operationId: 'things_post', parameters }, - }, - }, - }; - - const thingIndex = buildApiOperationIndex(spec, rules); - - expect(thingIndex.get('things_get')?.parameters.map(({ name }) => name)).toEqual(['limit']); - expect(thingIndex.get('things_post')?.parameters).toEqual([]); - }); - it('indexes a PATCH operation, which the published spec does not have yet', () => { const spec = { paths: { '/v2/things/{thingId}': { patch: { operationId: 'thing_patch', summary: 'Patch thing' } } }, From 991051f42a23b337a1066824dfb05bd6fc0af502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:08:03 +0200 Subject: [PATCH 067/103] test: Adapt the run-sync tests to the simplified blocklist The blocklist cases check a GET and a POST by method, without a tool name. The details cases check that a run-sync path lists only the POST, and the isApiOperationBlocked cases that the index drops each run-sync GET and keeps the POST. Removed the tests of the details tool's own refusal and of the write tool's check before it chooses a method; neither exists now. --- tests/unit/tools.apify_api.test.ts | 30 ++------------------ tests/unit/tools.apify_api_blocklist.test.ts | 28 +++++++++--------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 86deedccf..f9ecac8d1 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -279,15 +279,14 @@ describe('apify-api-details', () => { }); it.each(['/v2/actors/{actorId}/run-sync', 'acts/john~my-actor/run-sync-get-dataset-items', 'actors/abc/run-sync'])( - 'refuses the GET of the synchronous run endpoint %s and says why, without the spec', + 'says the synchronous run endpoint %s has no GET operation, only the POST', async (path) => { const result = await callTool(apifyApiDetails, { path, method: 'GET' }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe( - `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE} instead.`, + `The path /v2/${normalizeApiPath(path)} has no GET operation; it matches method POST.`, ); - expect(fetchApiOperationIndex).not.toHaveBeenCalled(); }, ); @@ -305,23 +304,6 @@ describe('apify-api-details', () => { expect(result.content[1].text).toBe(summary); }); - it.each(['actors/abc/run-sync/', 'actors//abc/run-sync', 'actors/abc/RUN-SYNC'])( - 'says the POST of %s, a synchronous run endpoint the lookup misses, is not in the spec, not that its GET is refused', - async (path) => { - const result = await callTool(apifyApiDetails, { path, method: 'POST' }); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toContain(`The path /v2/${path} is not in the API spec.`); - }, - ); - - it("names the write tool, not call-actor, which runs an Actor, in the refusal of a task's synchronous run GET", async () => { - const result = await callTool(apifyApiDetails, { path: 'actor-tasks/abc/run-sync', method: 'GET' }); - - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`); - }); - it('returns only the methods a test-only operation rule does not match', async () => { const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; @@ -337,14 +319,6 @@ describe('apify-api-details', () => { expect(withoutMethod.content[1].text).toBe('/v2/datasets/{datasetId}: PUT, DELETE.'); }); - it('names call-actor and the write tool in the refusal of a synchronous run GET only when the session has them', async () => { - const result = await callTool(apifyApiDetails, { path: 'actors/abc/run-sync', method: 'GET' }, [ - HELPER_TOOLS.API_DETAILS, - ]); - - expect(result.content[0].text).toBe(SYNC_RUN_REFUSAL); - }); - it('names the search tool on a path not in the spec only when the session has it', async () => { const withSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }); const withoutSearch = await callTool(apifyApiDetails, { path: 'acts/john/my-actor' }, [ diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index c961af0d8..9736aa116 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -170,9 +170,9 @@ describe('validateApiBlocklist()', () => { 'actors/abc/run-sync?timeout=300&token=x', normalizeApiPath('/v2/actors/abc/run-sync'), normalizeApiPath('v2/actor-tasks/abc/run-sync'), - ])('refuses a GET to %j in the read tool, not a POST in the write tool', (normalizedPath) => { + ])('refuses a GET to %j, not a POST', (normalizedPath) => { expect(validate({ normalizedPath })).toBe(SYNC_RUN_REFUSAL); - expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBeUndefined(); + expect(validate({ normalizedPath, method: 'POST' })).toBeUndefined(); }); it.each([ @@ -186,19 +186,9 @@ describe('validateApiBlocklist()', () => { 'actors/run-sync', 'actors/apify/hello-world/run-sync', 'datasets/run-sync', - ])('does not refuse %j', (normalizedPath) => { + ])('does not refuse a GET or a POST to %j', (normalizedPath) => { expect(validate({ normalizedPath })).toBeUndefined(); - expect(validate({ normalizedPath, toolName: HELPER_TOOLS.API_WRITE, method: 'POST' })).toBeUndefined(); - }); - - it('does not refuse the write tool without a method, since it never sends the GET', () => { - expect( - validate({ - normalizedPath: 'actors/abc/run-sync', - toolName: HELPER_TOOLS.API_WRITE, - method: undefined, - }), - ).toBeUndefined(); + expect(validate({ normalizedPath, method: 'POST' })).toBeUndefined(); }); it.each([ @@ -257,4 +247,14 @@ describe('isApiOperationBlocked()', () => { expect(isApiOperationBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); }); + + it.each([ + '/v2/actors/{actorId}/run-sync', + '/v2/actors/{actorId}/run-sync-get-dataset-items', + '/v2/actor-tasks/{actorTaskId}/run-sync', + '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + ])('blocks the GET of the synchronous run endpoint %s, not the POST', (path) => { + expect(isApiOperationBlocked('GET', path)).toBe(true); + expect(isApiOperationBlocked('POST', path)).toBe(false); + }); }); From 9bdb2a49b209a183fd372e83408b4069616b5fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:21:13 +0200 Subject: [PATCH 068/103] refactor: Check the match kind only where a blocklist rule can be a query parameter rule --- src/tools/api/apify_api_blocklist.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 26f0c23d1..1c7988db4 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -53,8 +53,8 @@ function splitRoutePath(path: string): string[] { } /** Whether an operation rule matches a method and a path, with values or a spec template. */ -function isOperationMatch(match: ApiBlockRule['match'], method: ApiMethod, path: string): boolean { - if (!('path' in match) || match.method !== method) return false; +function isOperationMatch(match: { method: ApiMethod; path: string }, method: ApiMethod, path: string): boolean { + if (match.method !== method) return false; const segments = splitRoutePath(path); const templateSegments = splitRoutePath(match.path); return ( @@ -98,5 +98,5 @@ export function validateApiBlocklist( /** Whether a rule matches an operation of the spec, given by its method and path template. */ export function isApiOperationBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { - return rules.some(({ match }) => isOperationMatch(match, method, path)); + return rules.some(({ match }) => 'path' in match && isOperationMatch(match, method, path)); } From 9bef709e666c1715394b12b549eddadaea89d6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:21:37 +0200 Subject: [PATCH 069/103] fix: Ask apify-api-write for the method when a rule blocks another write method of the path --- src/tools/api/apify_api_write.ts | 9 +++++++-- tests/unit/tools.apify_api.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index ee3017b26..fc4176ad7 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -7,6 +7,7 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; +import { isApiOperationBlocked } from './apify_api_blocklist.js'; import { API_CALL_DESCRIPTION, apiCallArgsShape, @@ -17,7 +18,7 @@ import { redactApiCallArgs, } from './apify_api_request.js'; import type { ApiMethod } from './apify_api_spec.js'; -import { fetchApiOperationIndex } from './apify_api_spec.js'; +import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiWriteArgs = z.object({ path: apiCallArgsShape.path, @@ -73,7 +74,11 @@ async function inferMethod( if (writeMethods.length === 0) { return { error: `The path has only the GET method, which this tool does not send${readTool}.` }; } - if (operations.length === 1) return { method: writeMethods[0] }; + // The index leaves out a blocked operation, so the one operation left may not be the path's only write method. + const hasBlockedWriteMethod = API_METHODS.some( + (method) => method !== 'GET' && isApiOperationBlocked(method, operations[0].path), + ); + if (operations.length === 1 && !hasBlockedWriteMethod) return { method: writeMethods[0] }; const prompt = writeMethods.length === 1 ? `The path matches method ${writeMethods[0]}; specify it to call the endpoint with.` diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 81c8c1590..1a79dcdee 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -820,6 +820,19 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it('asks for the method when a test-only operation rule blocks another write method of the path', async () => { + const rule: ApiBlockRule = { + match: { method: 'POST', path: '/v2/request-queues/{queueId}/requests/batch' }, + reason: 'No.', + }; + + const result = await callToolWithRule(apifyApiWrite, rule, { path: 'request-queues/q/requests/batch' }); + + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe('The path matches method DELETE; specify it to call the endpoint with.'); + expect(requestMock).not.toHaveBeenCalled(); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); From a170752acfc73b76b8967f4cf75045ef8e9b01bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:21:51 +0200 Subject: [PATCH 070/103] docs: Say which blocklist matches only fail closed --- src/tools/api/apify_api_blocklist.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 1c7988db4..1a30f6c52 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -30,8 +30,9 @@ function decodeEscapes(text: string): string { } /** - * A query parameter name as the API's query parser (qs, run by Express) reads it, decoded and in lowercase: - * qs reads `method[]`, `method[0]`, and `[method]` all as `method`. + * A query parameter name, decoded and in lowercase (both fail-closed, beyond what the API reads), without the + * brackets the API's query parser (qs, run by Express) reads through: qs reads `method[]`, `method[0]`, and + * `[method]` all as `method`. */ function extractQueryParamName(name: string): string { const decodedName = decodeEscapes(name).toLowerCase(); @@ -39,8 +40,8 @@ function extractQueryParamName(name: string): string { } /** - * A path's segments as the API's router compares them: in lowercase, without the empty segments of a trailing - * or doubled slash, and with the legacy `acts` prefix read as `actors`. Split before decoding, as the router + * A path's segments, matched fail-closed: in lowercase, each decoded, without empty segments (a trailing or + * doubled slash), and with the legacy `acts` prefix read as `actors`. Split before decoding, as the API's router * does, so the escaped slash in `apify%2Fhello-world` stays in its segment. */ function splitRoutePath(path: string): string[] { From 912b786f9ac4e3fd22829612ed803d02d1365c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:22:02 +0200 Subject: [PATCH 071/103] docs: Shorten the API blocklist paragraph in the tools AGENTS.md --- src/tools/AGENTS.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 7aeabd961..c999d2ad7 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -43,16 +43,11 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. operations on a path, and send a GET, or a POST, PUT, PATCH, or DELETE, to a path. The write tool is in no category (`UNCATEGORIZED_TOOLS`): `tools=api` serves the other three, and a session gets the write tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI. - They refuse what `API_BLOCK_RULES` in `apify_api_blocklist.ts` lists. A rule matches either a query - parameter, refused in every call, or one operation (a method and a spec path template), refused in the - tool that sends that method. It gives the reason shown to the agent and optionally tools to suggest, - each named only when the session has it and it is not the refusing tool. The one rule is the `method` - query parameter, which the API takes as the HTTP method. `callApi` checks every call - (`validateApiBlocklist`) as the API reads the request URL, and fail-closed: a path in any letter case, - decoded segment by segment, with trailing or doubled slashes, and `acts` read as `actors`; a query - name decoded, in any letter case, and with brackets. The index leaves out each operation a rule - matches (`isApiOperationBlocked`), so search, details, the closest-path hints, and the write tool's - method choice never offer it. + They refuse what `API_BLOCK_RULES` in `apify_api_blocklist.ts` lists: the `method` query parameter, + which the API takes as the HTTP method. `callApi` checks every call (`validateApiBlocklist`), + fail-closed, as the API reads the request URL, and the index leaves out each operation a rule matches + (`isApiOperationBlocked`), so search, details, the closest-path hints, and the write tool's method + choice never offer it. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it otherwise only for hints, waiting at most a few seconds, so a failed download does not stop a From 4c37704264b6f09c70813382ff994bd44d13435a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:22:16 +0200 Subject: [PATCH 072/103] test: Keep one apify-api-read case per way the method query parameter reaches the blocklist --- tests/unit/tools.apify_api.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 1a79dcdee..87f8fb43c 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -315,22 +315,8 @@ describe('apify-api-read', () => { it.each([ { path: 'datasets/abc', query: { method: 'DELETE' } }, - { path: 'datasets/abc', query: { METHOD: 'delete' } }, - { path: 'datasets/abc', query: { '%6Dethod': 'Delete' } }, - { path: 'datasets/abc', query: { 'method[]': 'DELETE' } }, - { path: 'datasets/abc', query: { 'method[0]': 'POST' } }, - { path: 'datasets/abc', query: { 'Method[x]': 'POST' } }, - // The API's query parser reads a leading bracket pair as the name. { path: 'datasets/abc', query: { '[method]': 'DELETE' } }, - { path: 'datasets/abc?method=delete' }, - { path: 'datasets/abc?METHOD=DELETE' }, - // The API decodes the names in the query string before it reads them. - { path: 'datasets/abc?limit=1&%6Dethod=DELETE' }, { path: 'datasets/abc?method%5B%5D=DELETE' }, - { path: 'datasets/abc?method[0]=DELETE' }, - { path: 'datasets/abc?%5Bmethod%5D=DELETE' }, - // The URL drops a tab before it is sent. - { path: 'datasets/abc?me\tthod=DELETE' }, ])('refuses the method query parameter in %j without a request', async (args) => { const result = await callTool(apifyApiRead, args); From 8438037370afeaa604449fd9135a117aa909657a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:23:13 +0200 Subject: [PATCH 073/103] docs: Shorten the comment on the run-sync blocklist rules --- src/tools/api/apify_api_blocklist.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index cc43d53f2..701af8d16 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -25,8 +25,7 @@ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ 'of the request.', suggestedToolNames: [HELPER_TOOLS.API_WRITE], }, - // A GET to a synchronous run endpoint starts a paid run, so the read tool would not be read-only; the write - // tool sends the POST. See apify/apify-mcp-server#1502. + // A GET here starts a paid run, so the read tool would not be read-only; see apify/apify-mcp-server#1502. { match: { method: 'GET', path: '/v2/actors/{actorId}/run-sync' }, reason: SYNC_RUN_GET_REASON, From 0604b0f4c3f20630821ec78dda6c3cc92718d1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:24:34 +0200 Subject: [PATCH 074/103] test: Keep one run-sync case per wiring path in the tool and index tests --- tests/unit/tools.apify_api.test.ts | 47 +------------------- tests/unit/tools.apify_api_blocklist.test.ts | 40 +++-------------- tests/unit/tools.apify_api_spec.test.ts | 21 +-------- 3 files changed, 9 insertions(+), 99 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 23a1c9614..f0ef2cf02 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -357,24 +357,8 @@ describe('apify-api-read', () => { it.each([ 'actors/apify~hello-world/run-sync', - 'actors/~my-actor/run-sync', - 'actors/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', - 'acts/apify~hello-world/run-sync', '/v2/acts/abc/run-sync-get-dataset-items?token=x', - 'v2/actors/abc/run-sync', - '/v2/actors/abc/run-sync', - // A slash encoded in a name is decoded after routing, so the name is one segment. - 'actors/apify%2Fhello-world/run-sync', - 'ACTORS/APIFY~HELLO-WORLD/RUN-SYNC-GET-DATASET-ITEMS', - 'actors//abc/run-sync', - 'actors/abc//run-sync', - 'actors/abc/run-sync//', - 'actors/abc/run-sync?build=latest', - // The URL is resolved before it is sent: dot segments, a backslash, and a newline. - 'actors/abc/x/../run-sync', - 'actors/abc/%2e%2e/abc/run-sync', 'actors\\abc\\run-sync', - 'actors/abc/run-\nsync', ])('refuses a GET to the synchronous run endpoint %j without a request', async (path) => { const result = await callTool(apifyApiRead, { path }); @@ -385,41 +369,14 @@ describe('apify-api-read', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it.each([ - 'actor-tasks/john~my-task/run-sync', - 'actor-tasks/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', - 'Actor-Tasks/john~my-task/Run-Sync/', - ])("refuses a GET to a task's synchronous run endpoint %j without a request or naming call-actor", async (path) => { - const result = await callTool(apifyApiRead, { path }); + it("refuses a GET to a task's synchronous run endpoint without a request or naming call-actor", async () => { + const result = await callTool(apifyApiRead, { path: 'actor-tasks/john~my-task/run-sync' }); expectSoftFailInvalidInput(result); expect(result.content[0].text).toBe(`${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`); expect(requestMock).not.toHaveBeenCalled(); }); - it.each([ - ['actors/abc/run-sync', [HELPER_TOOLS.API_READ], SYNC_RUN_REFUSAL], - [ - 'actors/abc/run-sync', - [HELPER_TOOLS.API_READ, HELPER_TOOLS.ACTOR_CALL], - `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.ACTOR_CALL} instead.`, - ], - [ - 'actors/abc/run-sync', - [HELPER_TOOLS.API_READ, HELPER_TOOLS.API_WRITE], - `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`, - ], - ['actor-tasks/abc/run-sync', [HELPER_TOOLS.API_READ, HELPER_TOOLS.ACTOR_CALL], SYNC_RUN_REFUSAL], - ])( - 'names call-actor and the write tool in the refusal of a GET to %s only when the session has them (%j)', - async (path, loadedToolNames, refusal) => { - const result = await callTool(apifyApiRead, { path }, loadedToolNames); - - expect(result.content[0].text).toBe(refusal); - expect(requestMock).not.toHaveBeenCalled(); - }, - ); - it.each(['actors/abc/runs', 'actors/abc/runs/last', 'key-value-stores/abc/records/run-sync', 'actor-runs/abc'])( 'sends a GET to %j, which does not start a run', async (path) => { diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 9736aa116..0ab291d3c 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -161,13 +161,6 @@ describe('validateApiBlocklist()', () => { 'acts/apify~hello-world/run-sync-get-dataset-items', 'actor-tasks/john~my-task/run-sync', 'actor-tasks/HG7ML7M8z78YcAPEB/run-sync-get-dataset-items', - // A slash encoded in a name is decoded after routing, so the name is one segment. - 'actors/apify%2Fhello-world/run-sync', - 'ACTORS/APIFY~HELLO-WORLD/RUN-SYNC', - 'Actor-Tasks/abc/Run-Sync-Get-Dataset-Items', - 'actors/abc/run-sync/', - 'actors//abc//run-sync', - 'actors/abc/run-sync?timeout=300&token=x', normalizeApiPath('/v2/actors/abc/run-sync'), normalizeApiPath('v2/actor-tasks/abc/run-sync'), ])('refuses a GET to %j, not a POST', (normalizedPath) => { @@ -192,35 +185,14 @@ describe('validateApiBlocklist()', () => { }); it.each([ - [ - [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE], - `Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE}`, - ], - [[HELPER_TOOLS.ACTOR_CALL], `Use ${HELPER_TOOLS.ACTOR_CALL}`], - [[HELPER_TOOLS.API_WRITE], `Use ${HELPER_TOOLS.API_WRITE}`], - ])( - 'names for an Actor call-actor and the write tool, each only when the session has it (%j)', - (loadedToolNames, use) => { - expect(validate({ normalizedPath: 'actors/abc/run-sync', loadedToolNames })).toBe( - `${SYNC_RUN_REFUSAL} ${use} instead.`, - ); - expect(validate({ normalizedPath: 'acts/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( - `${SYNC_RUN_REFUSAL} ${use} instead.`, - ); - }, - ); - - it('names for a task only the write tool, since call-actor cannot run a saved task', () => { + ['actors/abc/run-sync', `Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE}`], + ['actors/abc/run-sync-get-dataset-items', `Use ${HELPER_TOOLS.ACTOR_CALL} or ${HELPER_TOOLS.API_WRITE}`], + ['actor-tasks/abc/run-sync', `Use ${HELPER_TOOLS.API_WRITE}`], + ['actor-tasks/abc/run-sync-get-dataset-items', `Use ${HELPER_TOOLS.API_WRITE}`], + ])('names the tools to use instead of a GET to %s', (normalizedPath, use) => { const loadedToolNames = [HELPER_TOOLS.ACTOR_CALL, HELPER_TOOLS.API_WRITE]; - const withWriteTool = `${SYNC_RUN_REFUSAL} Use ${HELPER_TOOLS.API_WRITE} instead.`; - expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames })).toBe(withWriteTool); - expect(validate({ normalizedPath: 'actor-tasks/abc/run-sync-get-dataset-items', loadedToolNames })).toBe( - withWriteTool, - ); - expect( - validate({ normalizedPath: 'actor-tasks/abc/run-sync', loadedToolNames: [HELPER_TOOLS.ACTOR_CALL] }), - ).toBe(SYNC_RUN_REFUSAL); + expect(validate({ normalizedPath, loadedToolNames })).toBe(`${SYNC_RUN_REFUSAL} ${use} instead.`); }); }); }); diff --git a/tests/unit/tools.apify_api_spec.test.ts b/tests/unit/tools.apify_api_spec.test.ts index 4502ea860..96882e757 100644 --- a/tests/unit/tools.apify_api_spec.test.ts +++ b/tests/unit/tools.apify_api_spec.test.ts @@ -19,29 +19,10 @@ describe('buildApiOperationIndex()', () => { expect(index.size).toBe(37); }); - it('leaves out the GET of the synchronous run endpoints of Actors and tasks, and keeps the POST', () => { + it('leaves out the GET of the synchronous run endpoints and keeps the POST', () => { expect(index.has('actor_runSync_get')).toBe(false); expect(index.has('actor_runSync_post')).toBe(true); expect(index.has('actor_runSyncGetDatasetItems_post')).toBe(true); - const syncRunSpec = { - paths: Object.fromEntries( - [ - '/v2/actors/{actorId}/run-sync', - '/v2/actors/{actorId}/run-sync-get-dataset-items', - '/v2/actor-tasks/{actorTaskId}/run-sync', - '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', - ].map((path) => [path, { get: { operationId: `GET ${path}` }, post: { operationId: `POST ${path}` } }]), - ), - }; - - expect([...buildApiOperationIndex(syncRunSpec).keys()]).toEqual([ - 'POST /v2/actors/{actorId}/run-sync', - 'POST /v2/actors/{actorId}/run-sync-get-dataset-items', - 'POST /v2/actor-tasks/{actorTaskId}/run-sync', - 'POST /v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', - ]); - // The blocklist leaves the GETs out; without its rules, the index has them. - expect(buildApiOperationIndex(syncRunSpec, []).size).toBe(8); }); it('leaves out the operations a rule matches', () => { From 7e1f2f50f551011b05d509819327a6be5bfe0f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 8 Oct 2026 15:50:15 +0200 Subject: [PATCH 075/103] fix: Refuse a path that climbs out of /v2/ in the API call tools The tools prefix every path with /v2/, but a path such as ../acts/abc/run-sync resolves outside it, where no blocklist rule applies. The API serves only metadata there today; refusing such a path keeps the rules from being bypassed if that changes. Paths with or without the v2 prefix are unaffected. --- src/tools/AGENTS.md | 3 ++- src/tools/api/apify_api_blocklist.ts | 4 ++++ tests/unit/tools.apify_api_blocklist.test.ts | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index c999d2ad7..cb86f3f83 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -45,7 +45,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. tool only by naming it. The call tools are proxies to the API, like `apify api` in the Apify CLI. They refuse what `API_BLOCK_RULES` in `apify_api_blocklist.ts` lists: the `method` query parameter, which the API takes as the HTTP method. `callApi` checks every call (`validateApiBlocklist`), - fail-closed, as the API reads the request URL, and the index leaves out each operation a rule matches + fail-closed, as the API reads the request URL, and refuses a path whose dot segments climb out of `/v2/`; + the index leaves out each operation a rule matches (`isApiOperationBlocked`), so search, details, the closest-path hints, and the write tool's method choice never offer it. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 1a30f6c52..ed0251902 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -82,6 +82,10 @@ export function validateApiBlocklist( const { method, normalizedPath, query = {}, loadedToolNames } = request; // Parsed as the request URL is: dot segments resolved, a backslash read as a slash, tabs and newlines dropped. const { pathname, searchParams } = new URL(`https://api.invalid/v2/${normalizedPath}`); + // A path that climbs out of /v2/ reaches no route the rules are written for. + if (!pathname.startsWith('/v2/')) { + return 'The path leaves /v2/ once its dot segments are resolved; the API tools call only paths under /v2/.'; + } const queryNames = [...searchParams.keys(), ...Object.keys(query)].map(extractQueryParamName); const rule = rules.find(({ match }) => 'queryParam' in match diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 0a4826c51..d43b86fe5 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -147,6 +147,21 @@ describe('validateApiBlocklist()', () => { expect(validate({ normalizedPath: 'actors/abc/run-now', loadedToolNames }, [rule])).toBe(refusal); }); }); + describe('a path outside /v2/', () => { + const OUTSIDE_V2_REFUSAL = + 'The path leaves /v2/ once its dot segments are resolved; the API tools call only paths under /v2/.'; + + it.each(['../acts/abc/run-sync', '..', 'x/../../v-experimental/runs', '%2e%2e/health', '..\\ping'])( + 'refuses %s, whatever the rules', + (normalizedPath) => { + expect(validate({ normalizedPath }, [])).toBe(OUTSIDE_V2_REFUSAL); + }, + ); + + it.each(['actors/abc/../abc/runs', 'x/..', 'datasets/abc', ''])('lets %s through', (normalizedPath) => { + expect(validate({ normalizedPath }, [])).toBeUndefined(); + }); + }); }); describe('isApiOperationBlocked()', () => { From b7155d4dae6420b20280001c23aea13dfe192b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 10:16:41 +0200 Subject: [PATCH 076/103] test: Pin the /v2/ path check and the blocked GET case of apify-api-write --- tests/unit/tools.apify_api.test.ts | 10 ++++++++++ tests/unit/tools.apify_api_blocklist.test.ts | 17 +++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 87176981f..7e1ae23ab 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -824,6 +824,16 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); + it("uses the path's only write method when a test-only operation rule blocks its GET", async () => { + const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/actors/{actorId}/run-sync' }, reason: 'No.' }; + requestMock.mockResolvedValue(mockResponse(201, { data: {} })); + + await callToolWithRule(apifyApiWrite, rule, { path: 'actors/apify~hello-world/run-sync' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })); + }); + it('does not need the spec when the method is given, even for a path the spec does not list', async () => { requestMock.mockResolvedValue(mockResponse(201, { data: {} })); diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index d43b86fe5..99f3dce2e 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -151,12 +151,17 @@ describe('validateApiBlocklist()', () => { const OUTSIDE_V2_REFUSAL = 'The path leaves /v2/ once its dot segments are resolved; the API tools call only paths under /v2/.'; - it.each(['../acts/abc/run-sync', '..', 'x/../../v-experimental/runs', '%2e%2e/health', '..\\ping'])( - 'refuses %s, whatever the rules', - (normalizedPath) => { - expect(validate({ normalizedPath }, [])).toBe(OUTSIDE_V2_REFUSAL); - }, - ); + it.each([ + '../acts/abc/run-sync', + '..', + 'x/../../v-experimental/runs', + '%2e%2e/health', + '..\\ping', + '../v2', + '../v2x/runs', + ])('refuses %s, whatever the rules', (normalizedPath) => { + expect(validate({ normalizedPath }, [])).toBe(OUTSIDE_V2_REFUSAL); + }); it.each(['actors/abc/../abc/runs', 'x/..', 'datasets/abc', ''])('lets %s through', (normalizedPath) => { expect(validate({ normalizedPath }, [])).toBeUndefined(); From 608882b6197fcca3db01f990e7c830dd83b148d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 10:19:11 +0200 Subject: [PATCH 077/103] fix: Mark apify-api-read as not open-world again Master marked it open-world because a GET to a synchronous run endpoint starts an Actor run. This branch refuses those GETs, so the read tool starts no run and reaches nothing outside the Apify platform. --- src/tools/api/apify_api_read.ts | 4 ++-- tests/unit/tools.apify_api.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 04bb648d7..8a11f4f82 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -49,11 +49,11 @@ export const apifyApiRead: ToolEntry = Object.freeze({ annotations: { title: 'Read Apify API', // The blocklist refuses the GETs that would write or start a run: the method query parameter and the - // synchronous run endpoints. + // synchronous run endpoints. With no Actor run, the tool reaches nothing outside the Apify platform. readOnlyHint: true, destructiveHint: false, idempotentHint: true, - openWorldHint: true, + openWorldHint: false, }, // A storage signature, or the webhooks of a synchronous run, would otherwise be logged. redactArgs: redactApiCallArgs, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 4ad0f95e6..9a706e0f9 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -335,12 +335,12 @@ describe('apify-api-details', () => { }); describe('apify-api-read', () => { - it('is annotated as read-only and idempotent, since it refuses the GETs that start a run or write', () => { + it('is annotated as read-only, idempotent, and not open-world, since it refuses the GETs that start a run or write', () => { expect(apifyApiRead.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, - openWorldHint: true, + openWorldHint: false, }); }); From c2a66f18ec87886ffe3c5826da7472fff053f497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 10:19:23 +0200 Subject: [PATCH 078/103] fix: End the run-sync sentence of apify-api-read at "is refused." The tool description says what the tool does, not how the API behaves. --- src/tools/api/apify_api_read.ts | 2 +- tests/unit/tools.apify_api.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/api/apify_api_read.ts b/src/tools/api/apify_api_read.ts index 8a11f4f82..98f7da7fb 100644 --- a/src/tools/api/apify_api_read.ts +++ b/src/tools/api/apify_api_read.ts @@ -18,7 +18,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { const runActor = hasTool(HELPER_TOOLS.ACTOR_CALL) ? ` Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.` : ''; return dedent` Send a GET request to the Apify API at a path, such as /v2/actor-runs/abc. - A GET to the synchronous run endpoints is refused: it starts a paid run.${runActor} + A GET to the synchronous run endpoints is refused.${runActor} ${API_CALL_DESCRIPTION}${findPath}${getParameters} Returns the response body as the API sends it, JSON with its data wrapper included; a body over ${MAX_INLINE_BYTES} bytes is not returned. diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 9a706e0f9..d1a4eeeb3 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -347,7 +347,7 @@ describe('apify-api-read', () => { it('says a GET to the synchronous run endpoints is refused and names call-actor only when the session has it', () => { const withCallActor = apifyApiRead.buildDescription!(ALL_TOOLS_PRESENT); const withoutCallActor = apifyApiRead.buildDescription!(only(HELPER_TOOLS.API_READ)); - const refused = 'A GET to the synchronous run endpoints is refused: it starts a paid run.'; + const refused = 'A GET to the synchronous run endpoints is refused.'; expect(apifyApiRead.description).toBe(withCallActor); expect(withCallActor).toContain(`${refused} Run an Actor with ${HELPER_TOOLS.ACTOR_CALL}.`); From 3654dec7f426b2e419780016bbdbd74654da0d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 10:30:52 +0200 Subject: [PATCH 079/103] fix: Add the HTTP status hint to a failed write with an oversize error body --- src/tools/api/apify_api_request.ts | 5 +++-- tests/unit/tools.apify_api.test.ts | 24 +++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 3a2ae6373..8c959e10a 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -10,7 +10,7 @@ import { sendApifyApiRequest } from '../../apify_client.js'; import { APIFY_ERROR_TYPE_PAGE_NOT_FOUND, HELPER_TOOLS, MAX_INLINE_BYTES } from '../../const.js'; import { isApifyApiUri, isMaxContentLengthAbort } from '../../resources/api_resources.js'; import type { ToolResponse } from '../../utils/mcp.js'; -import { respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; +import { getHttpErrorHint, respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -317,9 +317,10 @@ export async function callApi(params: { } // The status says the write failed; the generic message would say it may have applied. if (statusCode !== undefined) { + const hint = getHttpErrorHint(statusCode); return respondUserError( `${method} ${path} failed with HTTP ${statusCode}. Its error body is larger than ` + - `${MAX_INLINE_BYTES} bytes, so it is not returned.`, + `${MAX_INLINE_BYTES} bytes, so it is not returned.${hint ? ` ${hint}` : ''}`, ); } return respondUserError( diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index f93364bfb..5210f6a0b 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -871,17 +871,23 @@ describe('apify-api-write', () => { ); }); - it('reports a failed write whose error body is over the inline limit with its status', async () => { - requestMock.mockRejectedValue(buildOversizeError(400)); + it.each([ + { statusCode: 400, hint: '' }, + { statusCode: 429, hint: ' Rate limit exceeded, wait before retrying.' }, + ])( + 'reports a failed write whose error body is over the inline limit with its status $statusCode', + async ({ statusCode, hint }) => { + requestMock.mockRejectedValue(buildOversizeError(statusCode)); - const result = await callTool(apifyApiWrite, { path: 'datasets/abc', method: 'PUT', body: { name: 'x' } }); + const result = await callTool(apifyApiWrite, { path: 'datasets/abc', method: 'PUT', body: { name: 'x' } }); - expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe( - `PUT /v2/datasets/abc failed with HTTP 400. Its error body is larger than ${MAX_INLINE_BYTES} bytes, ` + - 'so it is not returned.', - ); - }); + expectSoftFailInvalidInput(result); + expect(result.content[0].text).toBe( + `PUT /v2/datasets/abc failed with HTTP ${statusCode}. Its error body is larger than ` + + `${MAX_INLINE_BYTES} bytes, so it is not returned.${hint}`, + ); + }, + ); it('says the request was sent when an oversize response has no status', async () => { requestMock.mockRejectedValue(buildOversizeError()); From 1f26f1f9352d2531e8f7e2c8a888de64c94050bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:03:07 +0200 Subject: [PATCH 080/103] refactor: Name the API blocklist rule kinds endpoint rule and query rule Each kind has its own type (ApiEndpointRule, ApiQueryRule) and matcher (isEndpointMatch, isQueryMatch); isRuleMatch picks between them for both validateApiBlocklist and isEndpointBlocked, which replaces isApiOperationBlocked. The rule path, the request path, and the spec template go through normalizeApiPath before their segments are compared, so a rule path may be written without /v2. The check that refuses a path outside /v2/ is unchanged. --- src/tools/AGENTS.md | 2 +- src/tools/api/apify_api_blocklist.ts | 59 +++++++++++++------- src/tools/api/apify_api_spec.ts | 4 +- src/tools/api/apify_api_write.ts | 4 +- tests/unit/tools.apify_api.test.ts | 12 ++-- tests/unit/tools.apify_api_blocklist.test.ts | 18 +++--- 6 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 91298dcfb..118ceefe4 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -48,7 +48,7 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic. which the API takes as the HTTP method. `callApi` checks every call (`validateApiBlocklist`), fail-closed, as the API reads the request URL, and refuses a path whose dot segments climb out of `/v2/`; the index leaves out each operation a rule matches - (`isApiOperationBlocked`), so search, details, the closest-path hints, and the write tool's method + (`isEndpointBlocked`), so search, details, the closest-path hints, and the write tool's method choice never offer it. `apify_api_spec.ts` builds the operation index from the spec (cached for a day); search and details use it, the write tool uses it to choose a method that was not given, and a call uses it diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index ed0251902..a4bf6fd49 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -1,11 +1,17 @@ import type { HelperToolName } from '../../const.js'; import { HELPER_TOOLS } from '../../const.js'; +import { normalizeApiPath } from './apify_api_request.js'; import type { ApiMethod } from './apify_api_spec.js'; +/** A method and a spec path template, written with or without the `/v2` prefix. */ +export type ApiEndpointRule = { method: ApiMethod; path: string }; + +/** A query parameter name, refused in every call. */ +export type ApiQueryRule = { queryParam: string }; + /** A request the API tools refuse to send. */ export type ApiBlockRule = { - /** A query parameter, refused in every call, or one operation: a method and a spec path template. */ - match: { queryParam: string } | { method: ApiMethod; path: `/v2/${string}` }; + match: ApiEndpointRule | ApiQueryRule; /** Why the call is refused, shown to the agent. */ reason: string; /** Tools to use instead, each named only when the session has it and it is not the refusing tool. */ @@ -40,30 +46,46 @@ function extractQueryParamName(name: string): string { } /** - * A path's segments, matched fail-closed: in lowercase, each decoded, without empty segments (a trailing or - * doubled slash), and with the legacy `acts` prefix read as `actors`. Split before decoding, as the API's router - * does, so the escaped slash in `apify%2Fhello-world` stays in its segment. + * A path's segments, matched fail-closed: without the leading slash and `v2/` prefix (`normalizeApiPath`), in + * lowercase, each decoded, without empty segments (a trailing or doubled slash), and with the legacy `acts` prefix + * read as `actors`. Split before decoding, as the API's router does, so the escaped slash in `apify%2Fhello-world` + * stays in its segment. */ function splitRoutePath(path: string): string[] { - const segments = path + const segments = normalizeApiPath(path) .split('/') .filter(Boolean) .map((segment) => decodeEscapes(segment).toLowerCase()); - if (segments[0] === 'v2' && segments[1] === 'acts') segments[1] = 'actors'; + if (segments[0] === 'acts') segments[0] = 'actors'; return segments; } -/** Whether an operation rule matches a method and a path, with values or a spec template. */ -function isOperationMatch(match: { method: ApiMethod; path: string }, method: ApiMethod, path: string): boolean { - if (match.method !== method) return false; +/** Whether an endpoint rule matches a method and a path, with values or a spec template. */ +function isEndpointMatch(rule: ApiEndpointRule, method: ApiMethod, path: string): boolean { + if (rule.method !== method) return false; const segments = splitRoutePath(path); - const templateSegments = splitRoutePath(match.path); + const templateSegments = splitRoutePath(rule.path); return ( segments.length === templateSegments.length && templateSegments.every((segment, position) => segment.startsWith('{') || segment === segments[position]) ); } +/** Whether a query rule matches one of the query parameter names, as `extractQueryParamName` returns them. */ +function isQueryMatch(rule: ApiQueryRule, queryNames: readonly string[]): boolean { + return queryNames.includes(rule.queryParam.toLowerCase()); +} + +/** Whether a rule matches a request: an endpoint rule by its method and path, a query rule by its query names. */ +function isRuleMatch( + { match }: ApiBlockRule, + request: { method: ApiMethod; path: string; queryNames: readonly string[] }, +): boolean { + return 'queryParam' in match + ? isQueryMatch(match, request.queryNames) + : isEndpointMatch(match, request.method, request.path); +} + /** * Why a rule refuses a request, or `undefined` when none does. It matches fail-closed: a path in any letter * case, decoded segment by segment, and with trailing or doubled slashes; a query parameter name decoded, in @@ -87,11 +109,7 @@ export function validateApiBlocklist( return 'The path leaves /v2/ once its dot segments are resolved; the API tools call only paths under /v2/.'; } const queryNames = [...searchParams.keys(), ...Object.keys(query)].map(extractQueryParamName); - const rule = rules.find(({ match }) => - 'queryParam' in match - ? queryNames.includes(match.queryParam.toLowerCase()) - : isOperationMatch(match, method, pathname), - ); + const rule = rules.find((candidate) => isRuleMatch(candidate, { method, path: pathname, queryNames })); if (!rule) return undefined; // The read tool sends a GET, and the write tool every other method. const refusingToolName = method === 'GET' ? HELPER_TOOLS.API_READ : HELPER_TOOLS.API_WRITE; @@ -101,7 +119,10 @@ export function validateApiBlocklist( return toolNames.length > 0 ? `${rule.reason} Use ${toolNames.join(' or ')} instead.` : rule.reason; } -/** Whether a rule matches an operation of the spec, given by its method and path template. */ -export function isApiOperationBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { - return rules.some(({ match }) => 'path' in match && isOperationMatch(match, method, path)); +/** + * Whether a rule matches an endpoint of the spec, given by its method and path template. The spec gives no query + * values, so no query rule matches. + */ +export function isEndpointBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { + return rules.some((rule) => isRuleMatch(rule, { method, path, queryNames: [] })); } diff --git a/src/tools/api/apify_api_spec.ts b/src/tools/api/apify_api_spec.ts index ec84700d3..d4913a723 100644 --- a/src/tools/api/apify_api_spec.ts +++ b/src/tools/api/apify_api_spec.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { apifyApiOperationsCache } from '../../state.js'; import type { ApiBlockRule } from './apify_api_blocklist.js'; -import { isApiOperationBlocked } from './apify_api_blocklist.js'; +import { isEndpointBlocked } from './apify_api_blocklist.js'; export const APIFY_API_OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; @@ -156,7 +156,7 @@ export function buildApiOperationIndex(spec: unknown, rules?: readonly ApiBlockR if (!path.startsWith('/v2/') || !isRecord(pathItem)) continue; for (const method of API_METHODS) { const parsed = openApiOperationValidator.safeParse(pathItem[method.toLowerCase()]); - if (!parsed.success || parsed.data.deprecated || isApiOperationBlocked(method, path, rules)) continue; + if (!parsed.success || parsed.data.deprecated || isEndpointBlocked(method, path, rules)) continue; const { operationId, summary, description, tags, parameters, requestBody } = parsed.data; const body = parseRequestBody(requestBody, spec); index.set(operationId, { diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index fc4176ad7..3192310b0 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -7,7 +7,7 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondUserError } from '../../utils/mcp.js'; import { apifyApiCallOutputSchema } from '../structured_output_schemas.js'; -import { isApiOperationBlocked } from './apify_api_blocklist.js'; +import { isEndpointBlocked } from './apify_api_blocklist.js'; import { API_CALL_DESCRIPTION, apiCallArgsShape, @@ -76,7 +76,7 @@ async function inferMethod( } // The index leaves out a blocked operation, so the one operation left may not be the path's only write method. const hasBlockedWriteMethod = API_METHODS.some( - (method) => method !== 'GET' && isApiOperationBlocked(method, operations[0].path), + (method) => method !== 'GET' && isEndpointBlocked(method, operations[0].path), ); if (operations.length === 1 && !hasBlockedWriteMethod) return { method: writeMethods[0] }; const prompt = diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index b17bff664..f4ddf9531 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -263,7 +263,7 @@ describe('apify-api-details', () => { } }); - it('returns only the methods a test-only operation rule does not match', async () => { + it('returns only the methods a test-only endpoint rule does not match', async () => { const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; const refused = await callToolWithRule(apifyApiDetails, rule, { path: 'datasets/abc', method: 'GET' }); @@ -333,7 +333,7 @@ describe('apify-api-read', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it('refuses a path a test-only operation rule names, written with the /v2 prefix, without a request', async () => { + it('refuses a path a test-only endpoint rule names, written with the /v2 prefix, without a request', async () => { const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; const result = await callToolWithRule(apifyApiRead, rule, { path: '/v2/datasets/abc' }); @@ -790,7 +790,7 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it('refuses a method a test-only operation rule names, without a request or the spec', async () => { + it('refuses a method a test-only endpoint rule names, without a request or the spec', async () => { const rule: ApiBlockRule = { match: { method: 'DELETE', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc', method: 'DELETE' }); @@ -801,7 +801,7 @@ describe('apify-api-write', () => { expect(fetchApiOperationIndex).not.toHaveBeenCalled(); }); - it('asks only for a write method a test-only operation rule does not match', async () => { + it('asks only for a write method a test-only endpoint rule does not match', async () => { const rule: ApiBlockRule = { match: { method: 'DELETE', path: '/v2/datasets/{datasetId}' }, reason: 'No.' }; const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc' }); @@ -811,7 +811,7 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it('asks for the method when a test-only operation rule blocks another write method of the path', async () => { + it('asks for the method when a test-only endpoint rule blocks another write method of the path', async () => { const rule: ApiBlockRule = { match: { method: 'POST', path: '/v2/request-queues/{queueId}/requests/batch' }, reason: 'No.', @@ -824,7 +824,7 @@ describe('apify-api-write', () => { expect(requestMock).not.toHaveBeenCalled(); }); - it("uses the path's only write method when a test-only operation rule blocks its GET", async () => { + it("uses the path's only write method when a test-only endpoint rule blocks its GET", async () => { const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/actors/{actorId}/run-sync' }, reason: 'No.' }; requestMock.mockResolvedValue(mockResponse(201, { data: {} })); diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 99f3dce2e..a58f335a8 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; -import { isApiOperationBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { isEndpointBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; -/** A test-only operation rule. */ +/** A test-only endpoint rule. */ const RUN_NOW_RULE: ApiBlockRule = { match: { method: 'GET', path: '/v2/actors/{actorId}/run-now' }, reason: 'No run-now.', @@ -79,7 +79,7 @@ describe('validateApiBlocklist()', () => { ); }); - describe('an operation rule', () => { + describe('an endpoint rule', () => { it.each([ 'actors/abc/run-now', 'actors/abc/run-now?limit=1', @@ -169,26 +169,26 @@ describe('validateApiBlocklist()', () => { }); }); -describe('isApiOperationBlocked()', () => { +describe('isEndpointBlocked()', () => { it.each([ { method: 'GET', path: '/v2/actors/{actorId}/run-now', isBlocked: true }, { method: 'POST', path: '/v2/actors/{actorId}/run-now', isBlocked: false }, { method: 'GET', path: '/v2/actors/{actorId}/runs', isBlocked: false }, { method: 'GET', path: '/v2/actors/{actorId}', isBlocked: false }, ] as const)('returns $isBlocked for $method $path', ({ method, path, isBlocked }) => { - expect(isApiOperationBlocked(method, path, [RUN_NOW_RULE])).toBe(isBlocked); + expect(isEndpointBlocked(method, path, [RUN_NOW_RULE])).toBe(isBlocked); }); it('matches a literal segment of a rule only to that literal, not to a parameter of the spec', () => { const rules: ApiBlockRule[] = [{ match: { method: 'GET', path: '/v2/users/me' }, reason: 'No.' }]; - expect(isApiOperationBlocked('GET', '/v2/users/me', rules)).toBe(true); - expect(isApiOperationBlocked('GET', '/v2/users/{userId}', rules)).toBe(false); + expect(isEndpointBlocked('GET', '/v2/users/me', rules)).toBe(true); + expect(isEndpointBlocked('GET', '/v2/users/{userId}', rules)).toBe(false); }); - it('returns false for a query parameter rule', () => { + it('returns false for a query rule', () => { const rules: ApiBlockRule[] = [{ match: { queryParam: 'method' }, reason: 'No.' }]; - expect(isApiOperationBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); + expect(isEndpointBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); }); }); From d732bf88cceb7eafd08ec5bf454ce6e66ebf1927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:03:58 +0200 Subject: [PATCH 081/103] test: Match a blocklist rule path written without /v2 as its /v2 form --- tests/unit/tools.apify_api_blocklist.test.ts | 107 ++++++++++++------- 1 file changed, 70 insertions(+), 37 deletions(-) diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index a58f335a8..f7a84fefc 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -14,6 +14,55 @@ const RUN_NOW_RULE: ApiBlockRule = { reason: 'No run-now.', }; +/** The same rule with its path written without /v2. */ +const RUN_NOW_RULE_WITHOUT_V2: ApiBlockRule = { + ...RUN_NOW_RULE, + match: { method: 'GET', path: 'actors/{actorId}/run-now' }, +}; + +/** Normalized paths whose GET the run-now rule refuses. */ +const RUN_NOW_PATHS = [ + 'actors/abc/run-now', + 'actors/abc/run-now?limit=1', + // The API routes paths case-insensitively and ignores a trailing slash. + 'Actors/ABC/Run-Now', + 'actors/abc/run-now/', + // The API routes the legacy acts prefix like actors. + 'acts/john~my-actor/run-now', + 'ACTS/abc/run-now', + // A slash encoded in a name is decoded after routing, so the name is one segment. + 'actors/apify%2Fhello-world/run-now', + // The URL is resolved before it is sent: dot segments, a backslash, and a newline. + 'actors/abc/x/../run-now', + 'actors/abc/%2e%2e/abc/run-now', + 'actors\\abc\\run-now', + 'actors/abc/run-\nnow', + // Escapes in a literal segment are decoded, in case anything before the router decodes them. + 'actors/abc/run%2Dnow', + // Empty segments are ignored, in case anything before the router merges doubled slashes. + 'actors//abc//run-now', + 'actors/abc/run-now//', +]; + +/** Normalized paths whose GET the run-now rule lets through. */ +const NOT_RUN_NOW_PATHS = [ + 'actors/abc/run-now-x', + 'actors/run-now', + 'actors//run-now', + 'actors/apify/hello-world/run-now', + 'actor-runs/abc/run-now', + 'actsx/abc/run-now', + 'actors/abc/run-now/x', +]; + +/** Spec endpoints, each with whether the run-now rule blocks it. */ +const RUN_NOW_TEMPLATE_CASES = [ + { method: 'GET', path: '/v2/actors/{actorId}/run-now', isBlocked: true }, + { method: 'POST', path: '/v2/actors/{actorId}/run-now', isBlocked: false }, + { method: 'GET', path: '/v2/actors/{actorId}/runs', isBlocked: false }, + { method: 'GET', path: '/v2/actors/{actorId}', isBlocked: false }, +] as const; + /** Validates a GET in a session with no tools, unless the params say otherwise. */ function validate( params: Partial[0]> & { normalizedPath: string }, @@ -80,40 +129,11 @@ describe('validateApiBlocklist()', () => { }); describe('an endpoint rule', () => { - it.each([ - 'actors/abc/run-now', - 'actors/abc/run-now?limit=1', - // The API routes paths case-insensitively and ignores a trailing slash. - 'Actors/ABC/Run-Now', - 'actors/abc/run-now/', - // The API routes the legacy acts prefix like actors. - 'acts/john~my-actor/run-now', - 'ACTS/abc/run-now', - // A slash encoded in a name is decoded after routing, so the name is one segment. - 'actors/apify%2Fhello-world/run-now', - // The URL is resolved before it is sent: dot segments, a backslash, and a newline. - 'actors/abc/x/../run-now', - 'actors/abc/%2e%2e/abc/run-now', - 'actors\\abc\\run-now', - 'actors/abc/run-\nnow', - // Escapes in a literal segment are decoded, in case anything before the router decodes them. - 'actors/abc/run%2Dnow', - // Empty segments are ignored, in case anything before the router merges doubled slashes. - 'actors//abc//run-now', - 'actors/abc/run-now//', - ])('refuses a GET to %j', (normalizedPath) => { + it.each(RUN_NOW_PATHS)('refuses a GET to %j', (normalizedPath) => { expect(validate({ normalizedPath }, [RUN_NOW_RULE])).toBe('No run-now.'); }); - it.each([ - 'actors/abc/run-now-x', - 'actors/run-now', - 'actors//run-now', - 'actors/apify/hello-world/run-now', - 'actor-runs/abc/run-now', - 'actsx/abc/run-now', - 'actors/abc/run-now/x', - ])('does not refuse a GET to %j', (normalizedPath) => { + it.each(NOT_RUN_NOW_PATHS)('does not refuse a GET to %j', (normalizedPath) => { expect(validate({ normalizedPath }, [RUN_NOW_RULE])).toBeUndefined(); }); @@ -147,6 +167,17 @@ describe('validateApiBlocklist()', () => { expect(validate({ normalizedPath: 'actors/abc/run-now', loadedToolNames }, [rule])).toBe(refusal); }); }); + + describe('an endpoint rule with its path written without /v2', () => { + it.each(RUN_NOW_PATHS)('refuses a GET to %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [RUN_NOW_RULE_WITHOUT_V2])).toBe('No run-now.'); + }); + + it.each(NOT_RUN_NOW_PATHS)('does not refuse a GET to %j', (normalizedPath) => { + expect(validate({ normalizedPath }, [RUN_NOW_RULE_WITHOUT_V2])).toBeUndefined(); + }); + }); + describe('a path outside /v2/', () => { const OUTSIDE_V2_REFUSAL = 'The path leaves /v2/ once its dot segments are resolved; the API tools call only paths under /v2/.'; @@ -170,15 +201,17 @@ describe('validateApiBlocklist()', () => { }); describe('isEndpointBlocked()', () => { - it.each([ - { method: 'GET', path: '/v2/actors/{actorId}/run-now', isBlocked: true }, - { method: 'POST', path: '/v2/actors/{actorId}/run-now', isBlocked: false }, - { method: 'GET', path: '/v2/actors/{actorId}/runs', isBlocked: false }, - { method: 'GET', path: '/v2/actors/{actorId}', isBlocked: false }, - ] as const)('returns $isBlocked for $method $path', ({ method, path, isBlocked }) => { + it.each(RUN_NOW_TEMPLATE_CASES)('returns $isBlocked for $method $path', ({ method, path, isBlocked }) => { expect(isEndpointBlocked(method, path, [RUN_NOW_RULE])).toBe(isBlocked); }); + it.each(RUN_NOW_TEMPLATE_CASES)( + 'returns $isBlocked for $method $path when the rule path is written without /v2', + ({ method, path, isBlocked }) => { + expect(isEndpointBlocked(method, path, [RUN_NOW_RULE_WITHOUT_V2])).toBe(isBlocked); + }, + ); + it('matches a literal segment of a rule only to that literal, not to a parameter of the spec', () => { const rules: ApiBlockRule[] = [{ match: { method: 'GET', path: '/v2/users/me' }, reason: 'No.' }]; From 70207bfe9d6fd41ebcc18ccbc157d042ec043cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:04:45 +0200 Subject: [PATCH 082/103] refactor: Add isQueryBlocked to check query parameter names against the query rules --- src/tools/api/apify_api_blocklist.ts | 6 +++++ tests/unit/tools.apify_api_blocklist.test.ts | 28 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index a4bf6fd49..0a7ddc875 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -126,3 +126,9 @@ export function validateApiBlocklist( export function isEndpointBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { return rules.some((rule) => isRuleMatch(rule, { method, path, queryNames: [] })); } + +/** Whether a query rule matches one of the query parameter names, each read as `validateApiBlocklist` reads it. */ +export function isQueryBlocked(queryNames: readonly string[], rules = API_BLOCK_RULES): boolean { + const extractedNames = queryNames.map(extractQueryParamName); + return rules.some(({ match }) => 'queryParam' in match && isQueryMatch(match, extractedNames)); +} diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index f7a84fefc..560e22ef9 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; -import { isEndpointBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { isEndpointBlocked, isQueryBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = @@ -225,3 +225,29 @@ describe('isEndpointBlocked()', () => { expect(isEndpointBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); }); }); + +describe('isQueryBlocked()', () => { + it.each([ + { queryNames: ['method'] }, + { queryNames: ['METHOD'] }, + { queryNames: ['%6Dethod'] }, + { queryNames: ['method[]'] }, + { queryNames: ['[method]'] }, + { queryNames: ['limit', 'method'] }, + ])('returns true for $queryNames', ({ queryNames }) => { + expect(isQueryBlocked(queryNames)).toBe(true); + }); + + it.each([ + { queryNames: [] }, + { queryNames: ['methods'] }, + { queryNames: ['methodName'] }, + { queryNames: ['filter[method]'] }, + ])('returns false for $queryNames', ({ queryNames }) => { + expect(isQueryBlocked(queryNames)).toBe(false); + }); + + it('returns false for an endpoint rule', () => { + expect(isQueryBlocked(['method'], [RUN_NOW_RULE])).toBe(false); + }); +}); From 0f991cb1f992fbb502cf0e2d05935caba99e9f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:19:12 +0200 Subject: [PATCH 083/103] refactor: Check query rules in isQueryBlocked through isRuleMatch --- src/tools/api/apify_api_blocklist.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 0a7ddc875..bd9891451 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -76,14 +76,16 @@ function isQueryMatch(rule: ApiQueryRule, queryNames: readonly string[]): boolea return queryNames.includes(rule.queryParam.toLowerCase()); } -/** Whether a rule matches a request: an endpoint rule by its method and path, a query rule by its query names. */ +/** + * Whether a rule matches a request: an endpoint rule by its method and path, a query rule by its query names. + * A rule does not match when the request leaves out the part it checks. + */ function isRuleMatch( { match }: ApiBlockRule, - request: { method: ApiMethod; path: string; queryNames: readonly string[] }, + { method, path, queryNames = [] }: { method?: ApiMethod; path?: string; queryNames?: readonly string[] }, ): boolean { - return 'queryParam' in match - ? isQueryMatch(match, request.queryNames) - : isEndpointMatch(match, request.method, request.path); + if ('queryParam' in match) return isQueryMatch(match, queryNames); + return method !== undefined && path !== undefined && isEndpointMatch(match, method, path); } /** @@ -120,15 +122,15 @@ export function validateApiBlocklist( } /** - * Whether a rule matches an endpoint of the spec, given by its method and path template. The spec gives no query - * values, so no query rule matches. + * Whether a rule matches an endpoint of the spec, given by its method and path template. No query names are + * given, so no query rule matches. */ export function isEndpointBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { - return rules.some((rule) => isRuleMatch(rule, { method, path, queryNames: [] })); + return rules.some((rule) => isRuleMatch(rule, { method, path })); } /** Whether a query rule matches one of the query parameter names, each read as `validateApiBlocklist` reads it. */ export function isQueryBlocked(queryNames: readonly string[], rules = API_BLOCK_RULES): boolean { const extractedNames = queryNames.map(extractQueryParamName); - return rules.some(({ match }) => 'queryParam' in match && isQueryMatch(match, extractedNames)); + return rules.some((rule) => isRuleMatch(rule, { queryNames: extractedNames })); } From dbf9ea0a86754d76a1b48bb8cea0c61255746ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:20:21 +0200 Subject: [PATCH 084/103] refactor: Move normalizeApiPath to apify_api_path.ts --- src/tools/api/apify_api_blocklist.ts | 2 +- src/tools/api/apify_api_details.ts | 9 ++------- src/tools/api/apify_api_path.ts | 4 ++++ src/tools/api/apify_api_request.ts | 6 +----- src/tools/api/apify_api_write.ts | 2 +- tests/unit/tools.apify_api.test.ts | 8 ++------ 6 files changed, 11 insertions(+), 20 deletions(-) create mode 100644 src/tools/api/apify_api_path.ts diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index bd9891451..1210e0b4d 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -1,6 +1,6 @@ import type { HelperToolName } from '../../const.js'; import { HELPER_TOOLS } from '../../const.js'; -import { normalizeApiPath } from './apify_api_request.js'; +import { normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod } from './apify_api_spec.js'; /** A method and a spec path template, written with or without the `/v2` prefix. */ diff --git a/src/tools/api/apify_api_details.ts b/src/tools/api/apify_api_details.ts index 130694eb4..0a63a93cd 100644 --- a/src/tools/api/apify_api_details.ts +++ b/src/tools/api/apify_api_details.ts @@ -7,13 +7,8 @@ import { ALL_TOOLS_PRESENT, TOOL_TYPE } from '../../types.js'; import { compileSchema } from '../../utils/ajv.js'; import { respondOk, respondUserError } from '../../utils/mcp.js'; import { apifyApiDetailsOutputSchema } from '../structured_output_schemas.js'; -import { - findPathOperations, - formatApiPath, - formatList, - normalizeApiPath, - redactApiCallArgs, -} from './apify_api_request.js'; +import { normalizeApiPath } from './apify_api_path.js'; +import { findPathOperations, formatApiPath, formatList, redactApiCallArgs } from './apify_api_request.js'; import { API_METHODS, fetchApiOperationIndex } from './apify_api_spec.js'; const apifyApiDetailsArgs = z.object({ diff --git a/src/tools/api/apify_api_path.ts b/src/tools/api/apify_api_path.ts new file mode 100644 index 000000000..b08ed587d --- /dev/null +++ b/src/tools/api/apify_api_path.ts @@ -0,0 +1,4 @@ +/** A path without its leading slash and `v2/` prefix, as `apify api` takes it: `/v2/actors` becomes `actors`. */ +export function normalizeApiPath(path: string): string { + return path.replace(/^\//, '').replace(/^v2\//i, ''); +} diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 1f632c6a6..f083fc31f 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -13,6 +13,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; import { getHttpErrorHint, respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import { validateApiBlocklist } from './apify_api_blocklist.js'; +import { normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -70,11 +71,6 @@ export function redactApiCallArgs({ path, method, query, body }: Record Date: Fri, 9 Oct 2026 11:20:42 +0200 Subject: [PATCH 085/103] test: Read a blocklist rule path written with acts as actors --- tests/unit/tools.apify_api_blocklist.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 560e22ef9..d13255259 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -176,6 +176,12 @@ describe('validateApiBlocklist()', () => { it.each(NOT_RUN_NOW_PATHS)('does not refuse a GET to %j', (normalizedPath) => { expect(validate({ normalizedPath }, [RUN_NOW_RULE_WITHOUT_V2])).toBeUndefined(); }); + + it('reads a rule path written with acts as actors', () => { + const rule: ApiBlockRule = { ...RUN_NOW_RULE, match: { method: 'GET', path: 'acts/{actorId}/run-now' } }; + + expect(validate({ normalizedPath: 'actors/abc/run-now' }, [rule])).toBe('No run-now.'); + }); }); describe('a path outside /v2/', () => { From 11b5decec8114a19fc273a94c2b851a8b9120f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 11:21:36 +0200 Subject: [PATCH 086/103] test: Import normalizeApiPath from apify_api_path.ts in the blocklist tests --- tests/unit/tools.apify_api_blocklist.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 37a1e333e..5413eedc3 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; import { isEndpointBlocked, isQueryBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; -import { normalizeApiPath } from '../../src/tools/api/apify_api_request.js'; +import { normalizeApiPath } from '../../src/tools/api/apify_api_path.js'; import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = From 97b1605e1d2b42da8d1f98b7c57bfd47ce542e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 21:21:20 +0200 Subject: [PATCH 087/103] refactor: Read legacy path prefixes from one alias table The blocklist and the spec lookup each mapped the legacy acts prefix to actors in their own way. Both now read API_PATH_PREFIX_ALIASES in apify_api_path.ts, so a new alias is one entry. It is a Map, so a segment such as constructor never matches an inherited object property. --- src/tools/api/apify_api_blocklist.ts | 8 ++++---- src/tools/api/apify_api_path.ts | 7 +++++++ src/tools/api/apify_api_request.ts | 10 ++++------ tests/unit/tools.apify_api_blocklist.test.ts | 7 +++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 1210e0b4d..5a9b77197 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -1,6 +1,6 @@ import type { HelperToolName } from '../../const.js'; import { HELPER_TOOLS } from '../../const.js'; -import { normalizeApiPath } from './apify_api_path.js'; +import { API_PATH_PREFIX_ALIASES, normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod } from './apify_api_spec.js'; /** A method and a spec path template, written with or without the `/v2` prefix. */ @@ -47,8 +47,8 @@ function extractQueryParamName(name: string): string { /** * A path's segments, matched fail-closed: without the leading slash and `v2/` prefix (`normalizeApiPath`), in - * lowercase, each decoded, without empty segments (a trailing or doubled slash), and with the legacy `acts` prefix - * read as `actors`. Split before decoding, as the API's router does, so the escaped slash in `apify%2Fhello-world` + * lowercase, each decoded, without empty segments (a trailing or doubled slash), and with a legacy first segment + * read as the one the spec lists (`API_PATH_PREFIX_ALIASES`). Split before decoding, as the API's router does, so the escaped slash in `apify%2Fhello-world` * stays in its segment. */ function splitRoutePath(path: string): string[] { @@ -56,7 +56,7 @@ function splitRoutePath(path: string): string[] { .split('/') .filter(Boolean) .map((segment) => decodeEscapes(segment).toLowerCase()); - if (segments[0] === 'acts') segments[0] = 'actors'; + segments[0] = API_PATH_PREFIX_ALIASES.get(segments[0]) ?? segments[0]; return segments; } diff --git a/src/tools/api/apify_api_path.ts b/src/tools/api/apify_api_path.ts index b08ed587d..29768e45f 100644 --- a/src/tools/api/apify_api_path.ts +++ b/src/tools/api/apify_api_path.ts @@ -2,3 +2,10 @@ export function normalizeApiPath(path: string): string { return path.replace(/^\//, '').replace(/^v2\//i, ''); } + +/** + * Legacy first path segments the API routes to the same handlers as another, keyed by the legacy one. The spec + * lists only the other: apify-client and the CLI still send `acts`, which the API serves as `actors`. A `Map`, + * so a segment such as `constructor` never matches an inherited object property. + */ +export const API_PATH_PREFIX_ALIASES: ReadonlyMap = new Map([['acts', 'actors']]); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index f083fc31f..89077ba32 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -13,7 +13,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; import { getHttpErrorHint, respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import { validateApiBlocklist } from './apify_api_blocklist.js'; -import { normalizeApiPath } from './apify_api_path.js'; +import { API_PATH_PREFIX_ALIASES, normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -81,12 +81,10 @@ function stripQuery(path: string): string { return path.replace(/[?#][\s\S]*$/, ''); } -/** - * A normalized path as the spec lists it, without its query. The API routes the legacy `acts` prefix, - * which apify-client and the CLI send, to the same handler as `actors`, the only prefix the spec lists. - */ +/** A normalized path as the spec lists it: without its query, and with a legacy first segment replaced. */ function toSpecPath(normalizedPath: string): string { - return stripQuery(normalizedPath).replace(/^acts(?=\/|$)/, 'actors'); + const [prefix, ...rest] = stripQuery(normalizedPath).split('/'); + return [API_PATH_PREFIX_ALIASES.get(prefix) ?? prefix, ...rest].join('/'); } const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index d13255259..e24aa7a62 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -129,6 +129,13 @@ describe('validateApiBlocklist()', () => { }); describe('an endpoint rule', () => { + it('reads a first segment that is not an alias as it is, even one named like an object property', () => { + const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/constructor/{id}' }, reason: 'No.' }; + + expect(validate({ normalizedPath: 'constructor/abc' }, [rule])).toBe('No.'); + expect(isEndpointBlocked('GET', '/v2/constructor/{id}', [rule])).toBe(true); + }); + it.each(RUN_NOW_PATHS)('refuses a GET to %j', (normalizedPath) => { expect(validate({ normalizedPath }, [RUN_NOW_RULE])).toBe('No run-now.'); }); From d23eb25cc10a27ecacef2e3c594c85941fc56cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 21:24:59 +0200 Subject: [PATCH 088/103] refactor: Name the alias table API_RESOURCE_ALIASES It maps legacy names of top-level API resources, such as acts, to the name the spec lists. A whole path segment, not a prefix. --- src/tools/api/apify_api_blocklist.ts | 10 +++++----- src/tools/api/apify_api_path.ts | 8 ++++---- src/tools/api/apify_api_request.ts | 8 ++++---- tests/unit/tools.apify_api_blocklist.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 5a9b77197..24cbf7154 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -1,6 +1,6 @@ import type { HelperToolName } from '../../const.js'; import { HELPER_TOOLS } from '../../const.js'; -import { API_PATH_PREFIX_ALIASES, normalizeApiPath } from './apify_api_path.js'; +import { API_RESOURCE_ALIASES, normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod } from './apify_api_spec.js'; /** A method and a spec path template, written with or without the `/v2` prefix. */ @@ -47,16 +47,16 @@ function extractQueryParamName(name: string): string { /** * A path's segments, matched fail-closed: without the leading slash and `v2/` prefix (`normalizeApiPath`), in - * lowercase, each decoded, without empty segments (a trailing or doubled slash), and with a legacy first segment - * read as the one the spec lists (`API_PATH_PREFIX_ALIASES`). Split before decoding, as the API's router does, so the escaped slash in `apify%2Fhello-world` - * stays in its segment. + * lowercase, each decoded, without empty segments (a trailing or doubled slash), and with a legacy resource name + * read as the one the spec lists (`API_RESOURCE_ALIASES`). Split before decoding, as the API's router does, so the + * escaped slash in `apify%2Fhello-world` stays in its segment. */ function splitRoutePath(path: string): string[] { const segments = normalizeApiPath(path) .split('/') .filter(Boolean) .map((segment) => decodeEscapes(segment).toLowerCase()); - segments[0] = API_PATH_PREFIX_ALIASES.get(segments[0]) ?? segments[0]; + segments[0] = API_RESOURCE_ALIASES.get(segments[0]) ?? segments[0]; return segments; } diff --git a/src/tools/api/apify_api_path.ts b/src/tools/api/apify_api_path.ts index 29768e45f..08aebce75 100644 --- a/src/tools/api/apify_api_path.ts +++ b/src/tools/api/apify_api_path.ts @@ -4,8 +4,8 @@ export function normalizeApiPath(path: string): string { } /** - * Legacy first path segments the API routes to the same handlers as another, keyed by the legacy one. The spec - * lists only the other: apify-client and the CLI still send `acts`, which the API serves as `actors`. A `Map`, - * so a segment such as `constructor` never matches an inherited object property. + * Legacy names of top-level API resources, mapped to the name the spec lists. The API serves `acts`, which + * apify-client and the CLI still send, as `actors`. A `Map`, so a name such as `constructor` never matches an + * inherited object property. */ -export const API_PATH_PREFIX_ALIASES: ReadonlyMap = new Map([['acts', 'actors']]); +export const API_RESOURCE_ALIASES: ReadonlyMap = new Map([['acts', 'actors']]); diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 89077ba32..bf4dcab23 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -13,7 +13,7 @@ import type { ToolResponse } from '../../utils/mcp.js'; import { getHttpErrorHint, respondAborted, respondOk, respondUserError } from '../../utils/mcp.js'; import { WAIT_SECS_MAX } from '../actors/actor_run_response.js'; import { validateApiBlocklist } from './apify_api_blocklist.js'; -import { API_PATH_PREFIX_ALIASES, normalizeApiPath } from './apify_api_path.js'; +import { API_RESOURCE_ALIASES, normalizeApiPath } from './apify_api_path.js'; import type { ApiMethod, ApiOperation } from './apify_api_spec.js'; import { fetchApiOperationIndex, isRecord } from './apify_api_spec.js'; @@ -81,10 +81,10 @@ function stripQuery(path: string): string { return path.replace(/[?#][\s\S]*$/, ''); } -/** A normalized path as the spec lists it: without its query, and with a legacy first segment replaced. */ +/** A normalized path as the spec lists it: without its query, and with a legacy resource name replaced. */ function toSpecPath(normalizedPath: string): string { - const [prefix, ...rest] = stripQuery(normalizedPath).split('/'); - return [API_PATH_PREFIX_ALIASES.get(prefix) ?? prefix, ...rest].join('/'); + const [resource, ...rest] = stripQuery(normalizedPath).split('/'); + return [API_RESOURCE_ALIASES.get(resource) ?? resource, ...rest].join('/'); } const PATH_PARAMETER_SEGMENT_REGEX = /^\{[^{}]+\}$/; diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index e24aa7a62..014a07738 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -129,7 +129,7 @@ describe('validateApiBlocklist()', () => { }); describe('an endpoint rule', () => { - it('reads a first segment that is not an alias as it is, even one named like an object property', () => { + it('reads a resource name that is not an alias as it is, even one named like an object property', () => { const rule: ApiBlockRule = { match: { method: 'GET', path: '/v2/constructor/{id}' }, reason: 'No.' }; expect(validate({ normalizedPath: 'constructor/abc' }, [rule])).toBe('No.'); From 58606cfff0b259b21cfaf80c19399c71fcf1563a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 9 Oct 2026 21:29:45 +0200 Subject: [PATCH 089/103] refactor: Keep isRuleMatch strict and tell the rule kinds apart with isQueryRule isRuleMatch takes a full request again, with no optional parts. isEndpointBlocked and isQueryBlocked check only their own kind of rule, through isEndpointMatch and isQueryMatch, and isQueryRule is the one place that tells the kinds apart. --- src/tools/api/apify_api_blocklist.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 24cbf7154..5468c02fe 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -76,16 +76,19 @@ function isQueryMatch(rule: ApiQueryRule, queryNames: readonly string[]): boolea return queryNames.includes(rule.queryParam.toLowerCase()); } -/** - * Whether a rule matches a request: an endpoint rule by its method and path, a query rule by its query names. - * A rule does not match when the request leaves out the part it checks. - */ +/** Whether a rule is a query rule; any other rule is an endpoint rule. */ +function isQueryRule(match: ApiBlockRule['match']): match is ApiQueryRule { + return 'queryParam' in match; +} + +/** Whether a rule matches a request: an endpoint rule by its method and path, a query rule by its query names. */ function isRuleMatch( { match }: ApiBlockRule, - { method, path, queryNames = [] }: { method?: ApiMethod; path?: string; queryNames?: readonly string[] }, + request: { method: ApiMethod; path: string; queryNames: readonly string[] }, ): boolean { - if ('queryParam' in match) return isQueryMatch(match, queryNames); - return method !== undefined && path !== undefined && isEndpointMatch(match, method, path); + return isQueryRule(match) + ? isQueryMatch(match, request.queryNames) + : isEndpointMatch(match, request.method, request.path); } /** @@ -121,16 +124,13 @@ export function validateApiBlocklist( return toolNames.length > 0 ? `${rule.reason} Use ${toolNames.join(' or ')} instead.` : rule.reason; } -/** - * Whether a rule matches an endpoint of the spec, given by its method and path template. No query names are - * given, so no query rule matches. - */ +/** Whether an endpoint rule matches an endpoint of the spec, given by its method and path template. */ export function isEndpointBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { - return rules.some((rule) => isRuleMatch(rule, { method, path })); + return rules.some(({ match }) => !isQueryRule(match) && isEndpointMatch(match, method, path)); } /** Whether a query rule matches one of the query parameter names, each read as `validateApiBlocklist` reads it. */ export function isQueryBlocked(queryNames: readonly string[], rules = API_BLOCK_RULES): boolean { const extractedNames = queryNames.map(extractQueryParamName); - return rules.some((rule) => isRuleMatch(rule, { queryNames: extractedNames })); + return rules.some(({ match }) => isQueryRule(match) && isQueryMatch(match, extractedNames)); } From f159de9bb191ae2d933afcb2ad36928f9cfa38b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:29:39 +0200 Subject: [PATCH 090/103] refactor: Return early from the oversize branch of callApi's catch block --- src/tools/api/apify_api_request.ts | 36 ++++++++++++++---------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 1eada8847..2d562cb85 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -295,24 +295,9 @@ export async function callApi(params: { } catch (error) { // A cancelled call is not a tool error. if (params.signal?.aborted) return respondAborted(); - if (isMaxContentLengthAbort(error)) { - // A write has applied by the time its response arrives, so a large response is not a failure. - const statusCode = method === 'GET' ? undefined : readAbortedResponseStatus(error); - if (statusCode !== undefined && statusCode < 300) { - const structuredContent = { method, path, statusCode, data: null }; - const summary = - `${method} ${path} returned HTTP ${statusCode}. The response is larger than ` + - `${MAX_INLINE_BYTES} bytes, so it is not returned; check the result with a GET.`; - return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); - } - // The status says the write failed; the generic message would say it may have applied. - if (statusCode !== undefined) { - const hint = getHttpErrorHint(statusCode); - return respondUserError( - `${method} ${path} failed with HTTP ${statusCode}. Its error body is larger than ` + - `${MAX_INLINE_BYTES} bytes, so it is not returned.${hint ? ` ${hint}` : ''}`, - ); - } + if (!isMaxContentLengthAbort(error)) throw toPlainError(error); + const statusCode = method === 'GET' ? undefined : readAbortedResponseStatus(error); + if (statusCode === undefined) { return respondUserError( await formatOversizeMessage({ method, @@ -322,7 +307,20 @@ export async function callApi(params: { }), ); } - throw toPlainError(error); + // A write has applied by the time its response arrives, so a large response is not a failure. + if (statusCode < 300) { + const structuredContent = { method, path, statusCode, data: null }; + const summary = + `${method} ${path} returned HTTP ${statusCode}. The response is larger than ` + + `${MAX_INLINE_BYTES} bytes, so it is not returned; check the result with a GET.`; + return respondOk([JSON.stringify(structuredContent), summary], { structuredContent }); + } + // The status says the write failed; the generic message would say it may have applied. + const hint = getHttpErrorHint(statusCode); + return respondUserError( + `${method} ${path} failed with HTTP ${statusCode}. Its error body is larger than ` + + `${MAX_INLINE_BYTES} bytes, so it is not returned.${hint ? ` ${hint}` : ''}`, + ); } const data = maskToken(response.data, params.token); if (response.status >= 300) { From 2def4c53c08256389fc833729b20dbe69f9d6620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:30:37 +0200 Subject: [PATCH 091/103] refactor: Rename inferMethod to inferWriteMethod and ask for the method in one sentence --- src/tools/api/apify_api_write.ts | 15 ++++++--------- tests/unit/tools.apify_api.test.ts | 15 +++++---------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index ee3017b26..a3d94a2f3 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -55,10 +55,11 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * The only method of the path in the spec, or why the agent must give one. Only a call without a - * method needs the spec. The agent is asked only for the write methods; a GET goes to the read tool. + * Called only when the method is omitted. When the path has exactly one operation in the spec, it uses + * that operation's write method; otherwise it asks the caller to choose. The caller is asked only for + * the write methods; a GET goes to the read tool. */ -async function inferMethod( +async function inferWriteMethod( path: string, loadedToolNames: readonly string[], ): Promise<{ method: Exclude } | { error: string }> { @@ -74,12 +75,8 @@ async function inferMethod( return { error: `The path has only the GET method, which this tool does not send${readTool}.` }; } if (operations.length === 1) return { method: writeMethods[0] }; - const prompt = - writeMethods.length === 1 - ? `The path matches method ${writeMethods[0]}; specify it to call the endpoint with.` - : `The path matches methods ${formatList(writeMethods, 'and')}; specify which one to call the endpoint with.`; const getNote = writeMethods.length < operations.length ? ` This tool does not send its GET${readTool}.` : ''; - return { error: `${prompt}${getNote}` }; + return { error: `Specify a method for this path: ${formatList(writeMethods, 'or')}.${getNote}` }; } /** @@ -124,7 +121,7 @@ export const apifyApiWrite: ToolEntry = Object.freeze({ if ('error' in body) return respondUserError(body.error); const resolved = parsed.method ? { method: parsed.method } - : await inferMethod(parsed.path, toolArgs.loadedToolNames); + : await inferWriteMethod(parsed.path, toolArgs.loadedToolNames); if ('error' in resolved) return respondUserError(resolved.error); return callApi({ client: toolArgs.apifyClient, diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 2d7f0b057..12b127a70 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -803,10 +803,7 @@ describe('apify-api-write', () => { }); it.each([ - [ - 'request-queues/q/requests/batch', - 'The path matches methods POST and DELETE; specify which one to call the endpoint with.', - ], + ['request-queues/q/requests/batch', 'Specify a method for this path: POST or DELETE.'], ['not-in-spec/abc', 'The path is not in the API spec; specify the method.'], ])('asks for the method of %s without a request', async (path, message) => { const result = await callTool(apifyApiWrite, { path, body: { name: 'x' } }); @@ -819,13 +816,12 @@ describe('apify-api-write', () => { it.each([ [ 'datasets/abc', - 'The path matches methods PUT and DELETE; specify which one to call the endpoint with. This tool ' + - `does not send its GET; call it with ${HELPER_TOOLS.API_READ}.`, + 'Specify a method for this path: PUT or DELETE. This tool does not send its GET; call it with ' + + `${HELPER_TOOLS.API_READ}.`, ], [ 'webhooks', - 'The path matches method POST; specify it to call the endpoint with. This tool does not send its ' + - `GET; call it with ${HELPER_TOOLS.API_READ}.`, + `Specify a method for this path: POST. This tool does not send its GET; call it with ${HELPER_TOOLS.API_READ}.`, ], ])('asks only for a write method of %s and sends its GET to the read tool', async (path, message) => { const result = await callTool(apifyApiWrite, { path, body: { name: 'x' } }, [ @@ -842,8 +838,7 @@ describe('apify-api-write', () => { const result = await callTool(apifyApiWrite, { path: 'datasets/abc' }, [HELPER_TOOLS.API_WRITE]); expect(result.content[0].text).toBe( - 'The path matches methods PUT and DELETE; specify which one to call the endpoint with. This tool ' + - 'does not send its GET.', + 'Specify a method for this path: PUT or DELETE. This tool does not send its GET.', ); }); From 1eabae565d8759202883d7a36dc79a87589c3ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:33:26 +0200 Subject: [PATCH 092/103] test: Expect the one-sentence method prompt when a rule blocks another write method --- tests/unit/tools.apify_api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index f11cf6d8d..f881bb0b2 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -864,7 +864,7 @@ describe('apify-api-write', () => { const result = await callToolWithRule(apifyApiWrite, rule, { path: '/v2/datasets/abc' }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toContain('The path matches method PUT; specify it to call the endpoint with.'); + expect(result.content[0].text).toContain('Specify a method for this path: PUT.'); expect(requestMock).not.toHaveBeenCalled(); }); @@ -877,7 +877,7 @@ describe('apify-api-write', () => { const result = await callToolWithRule(apifyApiWrite, rule, { path: 'request-queues/q/requests/batch' }); expectSoftFailInvalidInput(result); - expect(result.content[0].text).toBe('The path matches method DELETE; specify it to call the endpoint with.'); + expect(result.content[0].text).toBe('Specify a method for this path: DELETE.'); expect(requestMock).not.toHaveBeenCalled(); }); From 64d0729ed5383be63e0174889437de1410dbf084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:33:26 +0200 Subject: [PATCH 093/103] refactor: Say in inferWriteMethod's docstring that a blocked write method makes it ask --- src/tools/api/apify_api_write.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index fdaed2b1b..ce3f044f4 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -56,9 +56,9 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Called only when the method is omitted. When the path has exactly one operation in the spec, it uses - * that operation's write method; otherwise it asks the caller to choose. The caller is asked only for - * the write methods; a GET goes to the read tool. + * Called only when the method is omitted. When the path has exactly one operation in the spec and no + * blocked write method, it uses that operation's write method; otherwise it asks the caller to choose. + * The caller is asked only for the write methods; a GET goes to the read tool. */ async function inferWriteMethod( path: string, From 17d1e87c4a1909e5b9ffed84fd5e973a83af85e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:41:47 +0200 Subject: [PATCH 094/103] refactor: Make inferWriteMethod's docstring match its only-GET refusal --- src/tools/api/apify_api_write.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index a3d94a2f3..1710afe2a 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -55,9 +55,9 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { } /** - * Called only when the method is omitted. When the path has exactly one operation in the spec, it uses - * that operation's write method; otherwise it asks the caller to choose. The caller is asked only for - * the write methods; a GET goes to the read tool. + * The method of a call that omits it: the path's only operation in the spec, when that is a write; + * otherwise why the caller must give one. The caller is asked only for the write methods; a GET goes + * to the read tool. */ async function inferWriteMethod( path: string, From cd31a6efea08cb04bf26c0943e7912ecfaa2ed01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:41:47 +0200 Subject: [PATCH 095/103] fix: Say the write tool returns the parsed response body, as the read tool does --- src/tools/api/apify_api_write.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index 1710afe2a..25f51fd72 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -39,8 +39,9 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { return dedent` Send a POST, PUT, PATCH, or DELETE request to the Apify API at a path, such as /v2/datasets/abc. ${API_CALL_DESCRIPTION}${findPath}${getParameters} - The request is sent once and applies at once. Returns the response body as the API sends it; a body - over ${MAX_INLINE_BYTES} bytes is not returned. + The request is sent once and applies at once. + Returns the parsed response body, JSON with its data wrapper included: integers above 2^53 can + lose precision. A body over ${MAX_INLINE_BYTES} bytes is not returned. Example call: {"path": "/v2/datasets/abc", "method": "PUT", "body": {"name": "leads-2026"}} From 1e57dea05c5f828e158c9a29c374617d72e6f9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 11:42:06 +0200 Subject: [PATCH 096/103] test: Pin HTTP 300 as a failed oversize write --- tests/unit/tools.apify_api.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 12b127a70..4332e7047 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -928,6 +928,7 @@ describe('apify-api-write', () => { }); it.each([ + { statusCode: 300, hint: '' }, { statusCode: 400, hint: '' }, { statusCode: 429, hint: ' Rate limit exceeded, wait before retrying.' }, ])( From 2054b7f3dc121ae7f7cfc30425d755e9600e4a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 12:49:16 +0200 Subject: [PATCH 097/103] docs: Say validateApiBlocklist also refuses a path that leaves /v2/ --- src/tools/api/apify_api_blocklist.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 5468c02fe..38d6b79db 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -92,9 +92,10 @@ function isRuleMatch( } /** - * Why a rule refuses a request, or `undefined` when none does. It matches fail-closed: a path in any letter - * case, decoded segment by segment, and with trailing or doubled slashes; a query parameter name decoded, in - * any letter case, and with brackets. + * Why the API tools refuse a request, or `undefined` when they send it: a path that leaves `/v2/` once resolved, + * whatever the rules, or a request a rule matches. Rules match fail-closed: a path in any letter case, decoded + * segment by segment, and with trailing or doubled slashes; a query parameter name decoded, in any letter case, + * and with brackets. */ export function validateApiBlocklist( request: { From 5f6469c18e148e7a097ea3c8e1cdf0ac169be616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 13:10:21 +0200 Subject: [PATCH 098/103] refactor: Remove isQueryBlocked, which has no caller --- src/tools/api/apify_api_blocklist.ts | 6 ----- tests/unit/tools.apify_api_blocklist.test.ts | 28 +------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 38d6b79db..0a133f930 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -129,9 +129,3 @@ export function validateApiBlocklist( export function isEndpointBlocked(method: ApiMethod, path: string, rules = API_BLOCK_RULES): boolean { return rules.some(({ match }) => !isQueryRule(match) && isEndpointMatch(match, method, path)); } - -/** Whether a query rule matches one of the query parameter names, each read as `validateApiBlocklist` reads it. */ -export function isQueryBlocked(queryNames: readonly string[], rules = API_BLOCK_RULES): boolean { - const extractedNames = queryNames.map(extractQueryParamName); - return rules.some(({ match }) => isQueryRule(match) && isQueryMatch(match, extractedNames)); -} diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 014a07738..332fc5951 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { HELPER_TOOLS } from '../../src/const.js'; import type { ApiBlockRule } from '../../src/tools/api/apify_api_blocklist.js'; -import { isEndpointBlocked, isQueryBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; +import { isEndpointBlocked, validateApiBlocklist } from '../../src/tools/api/apify_api_blocklist.js'; import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = @@ -238,29 +238,3 @@ describe('isEndpointBlocked()', () => { expect(isEndpointBlocked('GET', '/v2/datasets/{datasetId}', rules)).toBe(false); }); }); - -describe('isQueryBlocked()', () => { - it.each([ - { queryNames: ['method'] }, - { queryNames: ['METHOD'] }, - { queryNames: ['%6Dethod'] }, - { queryNames: ['method[]'] }, - { queryNames: ['[method]'] }, - { queryNames: ['limit', 'method'] }, - ])('returns true for $queryNames', ({ queryNames }) => { - expect(isQueryBlocked(queryNames)).toBe(true); - }); - - it.each([ - { queryNames: [] }, - { queryNames: ['methods'] }, - { queryNames: ['methodName'] }, - { queryNames: ['filter[method]'] }, - ])('returns false for $queryNames', ({ queryNames }) => { - expect(isQueryBlocked(queryNames)).toBe(false); - }); - - it('returns false for an endpoint rule', () => { - expect(isQueryBlocked(['method'], [RUN_NOW_RULE])).toBe(false); - }); -}); From 169802e32ad71441a465eebcb5aef1182027257c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 13:40:58 +0200 Subject: [PATCH 099/103] docs: Make the validateApiBlocklist docstring plain --- src/tools/api/apify_api_blocklist.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index 0a133f930..27a9fb2f1 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -92,10 +92,8 @@ function isRuleMatch( } /** - * Why the API tools refuse a request, or `undefined` when they send it: a path that leaves `/v2/` once resolved, - * whatever the rules, or a request a rule matches. Rules match fail-closed: a path in any letter case, decoded - * segment by segment, and with trailing or doubled slashes; a query parameter name decoded, in any letter case, - * and with brackets. + * Returns why a request must not be sent, or `undefined` if it can be sent. + * A request is refused when its path resolves outside `/v2/`, or when a rule in `API_BLOCK_RULES` matches it. */ export function validateApiBlocklist( request: { From 7fd3782a28b1481ee3095521d1ed6b899d80da6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 13:45:23 +0200 Subject: [PATCH 100/103] fix: Advise asynchronous runs only in apify-api-write apify-api-read refuses the synchronous run GETs, so the shared description keeps only the waitForFinish limit, and the write tool says to prefer asynchronous runs. --- src/tools/api/apify_api_request.ts | 3 +-- src/tools/api/apify_api_write.ts | 1 + tests/unit/tools.apify_api.test.ts | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/tools/api/apify_api_request.ts b/src/tools/api/apify_api_request.ts index 7238137a6..54e9a23d4 100644 --- a/src/tools/api/apify_api_request.ts +++ b/src/tools/api/apify_api_request.ts @@ -44,8 +44,7 @@ export const apiCallArgsShape = { export const API_CALL_DESCRIPTION = `A proxy to the Apify API, like apify api in the Apify CLI; the server adds the host and the API token. Write the path as actors, v2/actors, or /v2/actors, with its values in it. A name is written username~name, or ~name for one you own, as in /v2/actors/~my-actor. -Synchronous runs, and waitForFinish above ${WAIT_SECS_MAX} seconds, outlast the usual 60-second tool-call timeout, -so prefer asynchronous runs.`; +waitForFinish above ${WAIT_SECS_MAX} seconds outlasts the usual 60-second tool-call timeout.`; /** Query parameters whose values grant access or carry secrets; `webhooks` can hold webhook headers. */ const SECRET_QUERY_PARAMS: readonly string[] = ['token', 'signature', 'webhooks']; diff --git a/src/tools/api/apify_api_write.ts b/src/tools/api/apify_api_write.ts index b33994a87..65d25dffb 100644 --- a/src/tools/api/apify_api_write.ts +++ b/src/tools/api/apify_api_write.ts @@ -40,6 +40,7 @@ function buildDescription({ hasTool }: ToolDescriptionContext): string { return dedent` Send a POST, PUT, PATCH, or DELETE request to the Apify API at a path, such as /v2/datasets/abc. ${API_CALL_DESCRIPTION}${findPath}${getParameters} + Synchronous runs also outlast the 60-second tool-call timeout, so prefer asynchronous runs. The request is sent once and applies at once. Returns the parsed response body, JSON with its data wrapper included: integers above 2^53 can lose precision. A body over ${MAX_INLINE_BYTES} bytes is not returned. diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 00fd105a5..a527fab7d 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -351,6 +351,11 @@ describe('apify-api-read', () => { expect(withoutCallActor).not.toContain(HELPER_TOOLS.ACTOR_CALL); }); + it('gives no advice about synchronous runs, which it refuses', () => { + expect(apifyApiRead.description).toContain('waitForFinish above'); + expect(apifyApiRead.description).not.toContain('prefer asynchronous runs'); + }); + it.each([ 'actors/apify~hello-world/run-sync', '/v2/acts/abc/run-sync-get-dataset-items?token=x', @@ -851,8 +856,10 @@ describe('apify-api-write', () => { }); it('advises an asynchronous run without saying a synchronous run is refused, since it sends the POST', () => { - expect(apifyApiWrite.description).toContain('Synchronous runs, and waitForFinish above'); - expect(apifyApiWrite.description).toContain('so prefer asynchronous runs.'); + expect(apifyApiWrite.description).toContain('waitForFinish above'); + expect(apifyApiWrite.description).toContain( + 'Synchronous runs also outlast the 60-second tool-call timeout, so prefer asynchronous runs.', + ); expect(apifyApiWrite.description).not.toContain('refused'); }); From a8f3cd246fd2bebe7d4485158f77385b744e5177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 12 Oct 2026 14:13:10 +0200 Subject: [PATCH 101/103] fix: Refuse in resources/read what the API tools refuse readApiResource checked only the origin, so resources/read still sent a GET with the method query parameter, which can delete, and a GET to the synchronous run endpoints, which starts a paid run. It now runs the same validateApiBlocklist check as the API tools and throws InvalidParams without a request. Fixes #1524 --- src/resources/AGENTS.md | 4 +++- src/resources/api_resources.ts | 10 ++++++++++ tests/unit/resources.api.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/resources/AGENTS.md b/src/resources/AGENTS.md index 4ec8eee13..08b9bed72 100644 --- a/src/resources/AGENTS.md +++ b/src/resources/AGENTS.md @@ -27,7 +27,9 @@ scheme guidance (SHOULD only be used for client-fetchable URLs — ours are toke identity with the platform's own URLs is the feature. Revisit when tools start emitting `resource_link`s, where clients may fetch `https://` URIs directly. `isApifyApiUri()` gates reads to the configured API origin and rejects userinfo-bearing URLs (axios drops the `Authorization` -header for those, silently degrading to unauthenticated). The API tools' `callApi` +header for those, silently degrading to unauthenticated). `readApiResource()` then refuses, without a +request, what the API tools refuse (`validateApiBlocklist` in `../tools/api/apify_api_blocklist.ts`): the +`method` query parameter and a GET to the synchronous run endpoints. The API tools' `callApi` (`../tools/api/apify_api_request.ts`) reuses `isApifyApiUri()`, `isMaxContentLengthAbort()`, `maskSessionToken()`, `redactUrlSigningSecretKey()` and `REDACTED`, so a change to any changes the tools too. diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index f43bfe17b..813c24aea 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -9,6 +9,8 @@ import type { ApifyClient } from '../apify_client.js'; import { getApifyAPIBaseUrl, sendApifyApiRequest } from '../apify_client.js'; import { MAX_INLINE_BYTES } from '../const.js'; import { InternalError, InvalidParamsError } from '../mcp/errors.js'; +import { validateApiBlocklist } from '../tools/api/apify_api_blocklist.js'; +import { normalizeApiPath } from '../tools/api/apify_api_path.js'; import { parseBaseMimeType } from '../tools/storage/storage_helpers.js'; import { getHttpStatusCode, logHttpError } from '../utils/logging.js'; import { getHttpErrorHint } from '../utils/mcp.js'; @@ -222,6 +224,14 @@ export async function readApiResource(uri: string, apifyClient?: ApifyClient): P uri, }); } + // A GET with the method query parameter can delete, and one to a synchronous run endpoint starts a paid run. + const { pathname, search } = new URL(uri); + const refusal = validateApiBlocklist({ + method: 'GET', + normalizedPath: normalizeApiPath(`${pathname}${search}`), + loadedToolNames: [], + }); + if (refusal) throw new InvalidParamsError(`Failed to read ${uri}: ${refusal}`, { uri }); let response: { data: unknown; headers: Record; status: number; statusText: string }; try { diff --git a/tests/unit/resources.api.test.ts b/tests/unit/resources.api.test.ts index 46855860e..063160a5a 100644 --- a/tests/unit/resources.api.test.ts +++ b/tests/unit/resources.api.test.ts @@ -189,6 +189,21 @@ describe('readApiResource()', () => { expect(error.data).toEqual({ uri: 'https://example.com/steal-my-token' }); }); + it.each([ + `${API}/v2/datasets/ds-1?method=DELETE`, + `${API}/v2/datasets/ds-1?%6Dethod=DELETE`, + `${API}/v2/acts/apify~hello-world/run-sync`, + `${API}/v2/actor-tasks/abc/run-sync-get-dataset-items`, + ])('throws InvalidParams without a request for %s, as the API tools refuse it', async (uri) => { + const { request, captured } = requestReturning(streamOf('{}'), 'application/json'); + + const error = await expectReadError(readApiResource(uri, stubApifyClient({ request }))); + + expect(error).toBeInstanceOf(InvalidParamsError); + expect(error.data).toEqual({ uri }); + expect(captured.url).toBe(''); + }); + it('requests the full URL as a stream capped at MAX_INLINE_BYTES', async () => { // The size cap is enforced by axios itself mid-consumption (streamed responses are wrapped in a // byte-counting generator since axios 1.16), so the contract under test is the request config. From 0d8d93eb008af78c38334d35b8ab2ca23bbb6af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:31:33 +0000 Subject: [PATCH 102/103] test: Check the resources/read refusal text names no tool Also link #1524 from the guard comment and list the refusal among the readApiResource failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011QiQRhqm3wKyv6KFLncAqG --- src/resources/api_resources.ts | 7 ++++--- tests/unit/resources.api.test.ts | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/resources/api_resources.ts b/src/resources/api_resources.ts index 44d8c2274..b827dcaa6 100644 --- a/src/resources/api_resources.ts +++ b/src/resources/api_resources.ts @@ -210,8 +210,8 @@ export function redactJsonSecrets(json: string): string { * JSON primitives, formatting, and bytes round-trip exactly, except that the session token and * `urlSigningSecretKey` and `password` values in JSON become `[REDACTED]`. * - * Genuine failures (no token, bad origin, a missing resource, a bad token, a 5xx, a network error) - * throw a domain error (`InvalidParamsError`/`InternalError`) that the protocol adapters + * Genuine failures (no token, bad origin, a request the API tools refuse, a missing resource, a bad token, + * a 5xx, a network error) throw a domain error (`InvalidParamsError`/`InternalError`) that the protocol adapters * (`legacy_server.ts`, `stateless_server.ts`) map to a JSON-RPC error, so the SDK returns an error * rather than success-shaped content for an unreadable resource (see SEP-2164). A body over * `MAX_INLINE_BYTES` is NOT a failure — it is a successful read returning a download pointer. @@ -231,7 +231,8 @@ export async function readApiResource(uri: string, apifyClient?: ApifyClient): P uri, }); } - // A GET with the method query parameter can delete, and one to a synchronous run endpoint starts a paid run. + // A GET with the method query parameter can delete, and one to a synchronous run endpoint starts a paid run; + // see apify/apify-mcp-server#1524. const { pathname, search } = new URL(uri); const refusal = validateApiBlocklist({ method: 'GET', diff --git a/tests/unit/resources.api.test.ts b/tests/unit/resources.api.test.ts index 0643750d9..371bee702 100644 --- a/tests/unit/resources.api.test.ts +++ b/tests/unit/resources.api.test.ts @@ -12,6 +12,12 @@ import { mockApifyClient } from './helpers/tool_context.js'; const API = 'https://api.apify.com'; +const METHOD_PARAM_REFUSAL = + 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; + +const SYNC_RUN_REFUSAL = + 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; + // `contents[0]` is a text|blob union; narrow it in tests that read one shape. function firstContent(result: ReadResourceResult): { mimeType?: string; text?: string; blob?: string } { return result.contents[0] as { mimeType?: string; text?: string; blob?: string }; @@ -206,16 +212,18 @@ describe('readApiResource()', () => { }); it.each([ - `${API}/v2/datasets/ds-1?method=DELETE`, - `${API}/v2/datasets/ds-1?%6Dethod=DELETE`, - `${API}/v2/acts/apify~hello-world/run-sync`, - `${API}/v2/actor-tasks/abc/run-sync-get-dataset-items`, - ])('throws InvalidParams without a request for %s, as the API tools refuse it', async (uri) => { + [`${API}/v2/datasets/ds-1?method=DELETE`, METHOD_PARAM_REFUSAL], + [`${API}/v2/datasets/ds-1?%6Dethod=DELETE`, METHOD_PARAM_REFUSAL], + [`${API}/v2/acts/apify~hello-world/run-sync`, SYNC_RUN_REFUSAL], + [`${API}/v2/actor-tasks/abc/run-sync-get-dataset-items`, SYNC_RUN_REFUSAL], + ])('throws InvalidParams without a request for %s, as the API tools refuse it', async (uri, refusal) => { const { request, captured } = requestReturning(streamOf('{}'), 'application/json'); const error = await expectReadError(readApiResource(uri, stubApifyClient({ request }))); expect(error).toBeInstanceOf(InvalidParamsError); + // readApiResource does not know the session's tools, so the refusal names none. + expect(error.message).toBe(`Failed to read ${uri}: ${refusal}`); expect(error.data).toEqual({ uri }); expect(captured.url).toBe(''); }); From 32cab6a13e5fa452b5d8acfdcd95d7586da50f0f Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 15 Oct 2026 23:49:26 +0200 Subject: [PATCH 103/103] fix: Clarify method override refusal --- src/tools/api/apify_api_blocklist.ts | 4 +--- tests/unit/resources.api.test.ts | 2 +- tests/unit/tools.apify_api.test.ts | 2 +- tests/unit/tools.apify_api_blocklist.test.ts | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/tools/api/apify_api_blocklist.ts b/src/tools/api/apify_api_blocklist.ts index d67d35ea1..ed3cd4c5a 100644 --- a/src/tools/api/apify_api_blocklist.ts +++ b/src/tools/api/apify_api_blocklist.ts @@ -26,9 +26,7 @@ export const API_BLOCK_RULES: readonly ApiBlockRule[] = [ { // A GET with method=DELETE deletes, so the read tool could write; see apify/apify-mcp-server#1501. match: { queryParam: 'method' }, - reason: - 'The API tools do not send the method query parameter: the API would take it as the HTTP method ' + - 'of the request.', + reason: 'The method query parameter is not allowed because it can turn a read request into a write request.', suggestedToolNames: [HELPER_TOOLS.API_WRITE], }, // A GET here starts a paid run, so the read tool would not be read-only; see apify/apify-mcp-server#1502. diff --git a/tests/unit/resources.api.test.ts b/tests/unit/resources.api.test.ts index 371bee702..a7ea940ee 100644 --- a/tests/unit/resources.api.test.ts +++ b/tests/unit/resources.api.test.ts @@ -13,7 +13,7 @@ import { mockApifyClient } from './helpers/tool_context.js'; const API = 'https://api.apify.com'; const METHOD_PARAM_REFUSAL = - 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; + 'The method query parameter is not allowed because it can turn a read request into a write request.'; const SYNC_RUN_REFUSAL = 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 25fa2d2e8..49779fc97 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -89,7 +89,7 @@ const SYNC_RUN_REFUSAL = 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.'; const METHOD_PARAM_REFUSAL = - 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; + 'The method query parameter is not allowed because it can turn a read request into a write request.'; /** The URL axios sends for the last request, with the query parameters added. */ function readSentUrl(): string { diff --git a/tests/unit/tools.apify_api_blocklist.test.ts b/tests/unit/tools.apify_api_blocklist.test.ts index 4dc365b5a..9a1e3b696 100644 --- a/tests/unit/tools.apify_api_blocklist.test.ts +++ b/tests/unit/tools.apify_api_blocklist.test.ts @@ -7,7 +7,7 @@ import { normalizeApiPath } from '../../src/tools/api/apify_api_path.js'; import { API_METHODS } from '../../src/tools/api/apify_api_spec.js'; const METHOD_PARAM_REFUSAL = - 'The API tools do not send the method query parameter: the API would take it as the HTTP method of the request.'; + 'The method query parameter is not allowed because it can turn a read request into a write request.'; const SYNC_RUN_REFUSAL = 'A GET to the synchronous run endpoints starts a paid run, as a POST does, so the API tools do not send it.';