Repository navigation
fix: Refuse in resources/read what the API tools refuse - #1525
Merged
Merged
Conversation
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.
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.
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
This was referenced Oct 12, 2026
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>
This was referenced Oct 13, 2026
…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
2 tasks
jirispilka
self-requested a review
October 15, 2026 22:03
jirispilka
marked this pull request as ready for review
October 15, 2026 22:03
jirispilka
approved these changes
Oct 15, 2026
jirispilka
left a comment
Collaborator
There was a problem hiding this comment.
@DaveHanns I made a couple of changes and tested on my local, we are good to go!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Closes #1524.
resources/readallowed requests that could delete storage or start paid runs.What changed
Reuses the API blocklist to reject the
methodquery 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
method, Actor/task synchronous-run refusals, normal reads, and genuine 404s. The latest wording was rechecked in default and Apps mode.32cab6a1: 2,737 unit tests passed, one skipped. No integration tests run locally.Escaped/doubled
/v2prefixes 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.