Skip to content

fix: Refuse in resources/read what the API tools refuse - #1525

Merged
DaveHanns merged 145 commits into
masterfrom
fix/resource-read-blocklist
Oct 16, 2026
Merged

DaveHanns merged 145 commits into
masterfrom
fix/resource-read-blocklist

Conversation

@DaveHanns

@DaveHanns DaveHanns commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Why

Closes #1524. resources/read allowed requests that could delete storage or start paid runs.

What changed

Reuses the API blocklist to reject the method query parameter and Actor/task synchronous-run GETs before sending a request. Other reads are unchanged. Clarifies the shared refusal message.

Merged master without rebasing or force-pushing.

Notes for reviewers (human-written)

Proof it works

  • Regression tests fail without the guard and pass with it; blocked reads send no request.
  • Local mcpc verified plain/encoded method, Actor/task synchronous-run refusals, normal reads, and genuine 404s. The latest wording was rechecked in default and Apps mode.
  • All five required checks passed at 32cab6a1: 2,737 unit tests passed, one skipped. No integration tests run locally.

Escaped/doubled /v2 prefixes remain outside the blocklist; tested variants returned API 404s. Follow-up: #1604.

AI assistance: DaveHanns implemented the fix with Claude Code. Claude Code handled the master merge and initial verification; Codex clarified the wording and reran unit checks and mcpc probes.

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.
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 e64fcb9)
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.
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.
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.
# Conflicts:
#	README.md
#	src/const.ts
#	src/tools/api/apify_api_request.ts
#	src/tools/registry.ts
#	src/tools/structured_output_schemas.ts
#	tests/unit/tools.apify_api.test.ts
#	tests/unit/tools.mode_contract.test.ts
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.
…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.
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.
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 e64fcb9)
…torage 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.
# Conflicts:
#	src/tools/AGENTS.md
#	src/tools/api/apify_api_request.ts
#	tests/unit/helpers/apify_api_spec_fixture.ts
#	tests/unit/tools.apify_api_spec.test.ts
… 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.
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.
# Conflicts:
#	README.md
#	src/tools/AGENTS.md
#	src/tools/api/apify_api_request.ts
#	tests/unit/tools.apify_api.test.ts
#	tests/unit/tools.apify_api_spec.test.ts
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.
- 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.
- 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.
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.
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.
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].
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.
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.
…e 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.
# Conflicts:
#	src/tools/AGENTS.md
#	src/tools/api/apify_api_request.ts
#	src/tools/api/apify_api_spec.ts
#	tests/unit/tools.apify_api_spec.test.ts
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.
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.
jirispilka added a commit that referenced this pull request Oct 13, 2026
…1542)

## Why
Closes #1532. Stacked on #1541 (base
`fix/search-actors-keyword-guidance`).

A session without `call-actor`, `call-actor-widget` or `apify-api-write`
can run only its dedicated Actor tools, but some descriptions still told
the agent to call Store Actors. Two descriptions named dedicated tools
the session might not serve. RAG and search-actors also split on
immediacy ("today", "latest"), which does not distinguish generic web
content from detailed platform data.

## What changed
Without a general runner (`canRunAnyActor()` false):
- search-actors: "construct an Actor call directly" → "see what input an
Actor needs".
- search-actors-widget and fetch-actor-details-widget: drop the
find/fetch-schema/call wording. search-actors-widget gets #1533's run
caveat once, also in widget-only sessions.
- Apps instructions: drop "When the next step is running an Actor,
prefer silent lookups…".

In every session:
- search-actors names `apify/rag-web-browser`, and RAG names
`apify/web-fetch`, only when that tool is served.
- RAG is for generic web content; detailed data from a specific platform
comes from a Store Actor. RAG drops the Amazon/flights examples and
time-word triggers. search-actors sends generic web content to RAG and
prefers the Store for detailed platform data, not only for scraping
tools.
- search-actors: "When the user names a specific Actor, look up that
Actor directly instead of searching."

## Not obvious from the code
- **Store discovery guidance stays in every session, unlike #1532's
spec.** "Search the Apify Store first", selection by popularity, and the
RAG pointer to Store Actors are discovery advice, not run instructions.
With #1533's limitation line, they tell the user an Actor exists and
that https://mcp.apify.com runs it.
- **The RAG/search-actors routing change applies to all sessions**,
including all-tools. Expect eval movement on web-fetch and search-actors
routing cases.
- **The named-Actor sentence fixes a regression from the broadened
"prefer searching the Store first".** Without it, "What can
apify/instagarm-scraper do?" went to search-actors instead of
fetch-actor-details (evals below).
- **`canRunAnyActor()` is not an authorization boundary.**
`resources/read` can still forward synchronous Actor-run GETs and HTTP
method overrides (#1525); full connector execution containment needs
that separate fix.
- **Hosted server:** apify-mcp-server-internal has no test or code that
reads these strings.

## Notes for reviewers (human-written)

## Proof it works
- Staff review on the submitted code tree: type-check, lint,
format:check, and check:agents passed; 2,600 unit tests passed, 1
skipped.
- Independent mcpc surface probes: search-only, search/details in
default and apps mode, widget-only apps, and a general-runner control.
Synthetic startup token, telemetry disabled, no tool calls. Full hosted
connector validation still needs a real token.
- Each new regression test fails on the base branch.
- mcpc, local build at `eed1cce`, before the routing change: sessions
without a runner, with one, and with the Claude connector `--tools`
list, in default and apps mode. Expected text present, no `MUST`, no
served text names a tool missing from that session's `tools/list`. The
RAG and web-fetch gates are unit-tested only; mcpc needs a real token to
load Actor tools.
- mcpc at `e381a67`, Claude connector `--tools` list: 23 tools served,
no `call-actor`, no `tools.listChanged`, no `MUST`, no unserved tool
names.
- Local evals (haiku-4-5 agent, sonnet judge), base `1b56a73` →
`e381a67` → this head:
  - pr tier, 125 items × 2: 242/250 → 244/250.
- Items whose text changed (RAG, search-actors, fetch-actor-details,
call-actor, web-fetch; 59 items, 5–10 trials each): 301/304 → 296/305 →
186/187.
  - `fetch-actor-details/typo-actor-name`: 7/10 → 2/10 → 7/8.
- `rag-web-browser/flight-prices-nyc-london` now goes to search-actors
(RAG 4/5 → 0/5), still a pass.
- merge tier, 50 items (tasks and schedules excluded): 33/50 → 37/50.
The two items that flipped to fail passed on rerun or failed on RAG
output, with identical tool calls.
- No eval item covers sessions without a runner, the widget text or the
apps bullet.

AI-assisted: Claude Code wrote the change and tests, and ran the mcpc
probes and evals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KaSMpLpjTVNEqmrNhjG1qJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
…locklist

Resolve src/resources/AGENTS.md: keep master's two-origin and redaction
text and add the blocklist sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QiQRhqm3wKyv6KFLncAqG
Also link #1524 from the guard comment and list the refusal among the
readApiResource failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QiQRhqm3wKyv6KFLncAqG
@jirispilka
jirispilka self-requested a review October 15, 2026 22:03
@jirispilka
jirispilka marked this pull request as ready for review October 15, 2026 22:03
@apify-service-account apify-service-account added the tested Temporary label used only programatically for some analytics. label Oct 15, 2026
@apify-service-account apify-service-account added the t-builders Issues owned by the Builders team. label Oct 15, 2026

@jirispilka jirispilka left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DaveHanns I made a couple of changes and tested on my local, we are good to go!

@DaveHanns
DaveHanns merged commit 9a406b2 into master Oct 16, 2026
36 of 41 checks passed
@DaveHanns
DaveHanns deleted the fix/resource-read-blocklist branch October 16, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-builders Issues owned by the Builders team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: resources/read still sends the method override and run-sync GETs that apify-api-read refuses

4 participants