Skip to content

feat: Async call-actor with uniform response + waitSecs on get-actor-run #582

Description

@jirispilka

Context and motivation

call-actor in default mode blocks indefinitely until the Actor finishes. This is problematic for long-running Actors — the tool call can hang for minutes or hours. Additionally, the response shape differs between sync and async modes, the dataset preview is misleading for arbitrary Actors, and the tool surface (get-actor-run / get-actor-output / get-dataset-items) has confusing overlap.

This issue replaces #579 with a cleaner approach derived from detailed analysis. The core idea:

  • call-actor always starts the Actor and returns immediately with run metadata (uniform shape)
  • In MCP task mode, call-actor waits internally for completion (task stays "working" with progress updates)
  • get-actor-run gains a waitSecs parameter for non-task clients to do bounded server-side waits
  • get-actor-output becomes the single way to get actual data
  • Each tool has one clear role: start → wait → get data

Supersedes #579.

Status (2026-05-04): Split into three sub-issues for implementation:

Original PR #594 closed in favor of the split. Out-of-scope follow-ups tracked in #587.

Scope

In scope

  • Refactor call-actor (default mode) to always return immediately with uniform run metadata
  • Add internal task-mode wait: when running inside an MCP task, call-actor waits for the Actor to reach a terminal state before returning (task stays "working" with progress updates)
  • Add waitSecs parameter to get-actor-run (default 10s, max 60s)
  • Remove async and previewOutput parameters from call-actor schema
  • Remove dataset preview from both call-actor and get-actor-run responses
  • Add storages (defaultDatasetId, defaultKeyValueStoreId) and hint to both tools' structuredContent
  • Add storages and hint to OpenAI mode call-actor structuredContent
  • New actorRunOutputSchema shared by both call-actor and get-actor-run
  • Split schemas: new schema for call-actor, old schema stays for direct actor tools
  • Update tool descriptions, server instructions, and output schemas
  • Widget compatibility: Update ActorRun widget to pass waitSecs: 0 in polling calls
  • Cancellation: Ensure task-mode call-actor cancellation properly aborts the Actor run via abort signal chain
  • Docs: Update README to reflect new workflow
  • Unit + integration test updates

Out of scope

  • Direct actor tool changes (RAG web browser, etc. keep blocking + old response shape) — follow-up
  • get-actor-output accepting runId as alternative to datasetId — follow-up
  • taskSupport on get-actor-run — deferred
  • Resource links in tool results (future)
  • Dynamic server resources + resource/list_changed (future)
  • Auto-wait for some clients (future)

Technical design

Overview

Two changes to existing tools, no new tools needed:

  1. call-actor removes the sync wait path entirely. Always calls actorClient.start() and returns immediately. In MCP task mode (detected via server-internal mcpTaskExecution flag on InternalToolArgs), it additionally calls waitForFinish() before returning — the MCP task system wraps this in a background promise, so the client sees a "working" task with progress updates.

  2. get-actor-run adds optional waitSecs parameter (default 10, max 60). When > 0, uses apifyClient.run(runId).waitForFinish({ waitSecs }) which polls the run at short intervals and returns immediately when the run reaches a terminal statewaitSecs is a maximum wait time, not a fixed delay. A 3-second Actor with waitSecs: 10 returns in ~3 seconds, not 10.

Client flows

Task client (2 tool calls):

call-actor (task) ──[task stays "working", progress updates]──> {status: SUCCEEDED, storages} → get-actor-output

Non-task client (3+ tool calls):

call-actor → {status: RUNNING, hint: "Use get-actor-run to wait for the Actor run to finish"}
  → get-actor-run(runId, waitSecs:10) → {status: RUNNING, hint: "Actor is still running, call get-actor-run again"}
  → get-actor-run(runId, waitSecs:10) → {status: SUCCEEDED, hint: "Use get-actor-output to retrieve Actor run results"}
  → get-actor-output(datasetId) → data

OpenAI/widget (1 tool call + widget polling):

call-actor → {status: RUNNING, storages} + widget meta → widget handles polling
  widget polls get-actor-run(runId, waitSecs: 0) → instant status checks

Sequence diagrams

Task client
sequenceDiagram
    participant LLM
    participant Client
    participant Server
    participant Apify

    LLM->>Client: call-actor(web-scraper, input)
    Client->>Server: tools/call + task params
    Server->>Apify: actor.start(input)
    Apify-->>Server: ActorRun (RUNNING)
    Server-->>Client: Task created (taskId)

    Note over Server: Background: waitForFinish()
    loop Task polling (MCP protocol)
        Client->>Server: tasks/get
        Server-->>Client: status: working, "Actor running (15s)..."
    end

    Apify-->>Server: Run finished (SUCCEEDED)
    Server-->>Client: task: completed
    Client->>Server: tasks/result
    Server-->>Client: {runId, status: SUCCEEDED, storages, hint}
    Client-->>LLM: Result with storages

    LLM->>Client: get-actor-output(datasetId)
    Client->>Server: tools/call
    Server->>Apify: dataset.listItems()
    Apify-->>Server: items
    Server-->>Client: {items, totalItemCount}
    Client-->>LLM: Data
Loading
Non-task client
sequenceDiagram
    participant LLM
    participant Client
    participant Server
    participant Apify

    LLM->>Client: call-actor(web-scraper, input)
    Client->>Server: tools/call (no task)
    Server->>Apify: actor.start(input)
    Apify-->>Server: ActorRun (RUNNING)
    Server-->>Client: {runId, status: RUNNING, storages, hint}
    Client-->>LLM: "Use get-actor-run to wait"

    LLM->>Client: get-actor-run(runId, waitSecs: 10)
    Client->>Server: tools/call
    Server->>Apify: run.waitForFinish({waitSecs: 10})
    Note over Server,Apify: Polls run status, returns early if done
    Apify-->>Server: Still RUNNING (timeout reached)
    Server-->>Client: {status: RUNNING, hint: "call get-actor-run again"}
    Client-->>LLM: Still running

    LLM->>Client: get-actor-run(runId, waitSecs: 10)
    Client->>Server: tools/call
    Server->>Apify: run.waitForFinish({waitSecs: 10})
    Apify-->>Server: SUCCEEDED (returned early)
    Server-->>Client: {status: SUCCEEDED, storages, hint}
    Client-->>LLM: "Use get-actor-output"

    LLM->>Client: get-actor-output(datasetId)
    Client->>Server: tools/call
    Server-->>Client: {items, totalItemCount}
    Client-->>LLM: Data
Loading
OpenAI/widget
sequenceDiagram
    participant LLM
    participant Client
    participant Server
    participant Apify
    participant Widget

    LLM->>Client: call-actor(web-scraper, input)
    Client->>Server: tools/call
    Server->>Apify: actor.start(input)
    Apify-->>Server: ActorRun (RUNNING)
    Server-->>Client: {runId, storages, hint} + widget meta
    Client-->>LLM: Result + widget renders

    loop Widget auto-polling (waitSecs: 0)
        Widget->>Server: get-actor-run(runId, waitSecs: 0)
        Server->>Apify: run.get()
        Apify-->>Server: status
        Server-->>Widget: {status, storages, stats?, finishedAt?}
    end

    Note over Widget: Shows completion in UI
    Note over LLM: Done — widget handled everything
Loading
Cancellation in task mode
sequenceDiagram
    participant Client
    participant Server
    participant Apify

    Client->>Server: tools/call + task (call-actor)
    Server->>Apify: actor.start(input)
    Server-->>Client: Task created (taskId)

    Note over Server: Background: waitForFinish()

    Client->>Server: tasks/cancel(taskId)
    Server->>Server: abortController.abort()
    Note over Server: waitForRunWithAbort detects abort
    Server->>Apify: run.abort()
    Note over Server: Task transitions to cancelled
Loading

Detailed design

Response shapes

call-actor response (base shape):

structuredContent: {
    runId: string;
    actorName: string;
    status: string;       // RUNNING, SUCCEEDED, FAILED, ABORTED, TIMED-OUT
    startedAt: string;    // ISO timestamp
    storages: {
        defaultDatasetId: string;
        defaultKeyValueStoreId: string;
    };
    hint: string;
}

get-actor-run response (base + diagnostic fields):

structuredContent: {
    runId: string;
    actorName: string;
    status: string;
    startedAt: string;
    finishedAt?: string;  // only for completed runs
    stats?: {             // run diagnostics (compute units, memory, etc.)
        ...
    };
    storages: {
        defaultDatasetId: string;
        defaultKeyValueStoreId: string;
    };
    hint: string;
}

get-actor-run keeps finishedAt and stats because:

  • The ActorRun widget reads them for display (computeUnits, memoryAvgBytes, memoryMaxBytes)
  • They're lightweight diagnostic fields already present in the run object
  • call-actor naturally doesn't have them (run just started)

Hint values by status (always reference Apify tool names):

  • RUNNING / READY → "Use get-actor-run to wait for the Actor run to finish"
  • SUCCEEDED → "Use get-actor-output with the datasetId from storages to retrieve Actor run results"
  • FAILED / ABORTED / TIMED-OUT → "Actor run failed. Use get-actor-log to inspect what went wrong"

1. mcpTaskExecution flag (server-internal)

Add mcpTaskExecution?: boolean to InternalToolArgs in src/types.ts. This is a server-internal flag — NOT a tool input parameter. The server sets it in executeToolAndUpdateTask when the client requested task-augmented execution (tools/call with task params). The LLM never sees it.

2. call-actor always returns immediately

src/tools/default/call_actor.ts: Remove the entire sync path (callActorGetDataset call). Always call actorClient.start(). In task mode (mcpTaskExecution === true), additionally call waitForFinish() with abort signal racing — the MCP task wraps this in a background promise.

src/tools/core/call_actor_common.ts:

  • Remove async and previewOutput parameters from Zod schema entirely
  • Add buildCallActorStructuredContent() shared response builder
  • Add getHintForStatus() hint mapper
  • Extract waitForRunWithAbort() helper (reuse abort-racing pattern from actor_execution.ts)

3. get-actor-run with waitSecs

src/tools/core/get_actor_run_common.ts:

  • Add waitSecs to schema: z.number().int().min(0).max(60).default(10) — "Maximum seconds to wait for the Actor run to finish (default 10, max 60). The server polls the run at short intervals and returns immediately when a terminal state is reached — waitSecs is a ceiling, not a fixed delay. If the run is still active after this time, returns current status — call again to continue waiting. Use 0 for an instant status check without waiting."
  • Remove dataset preview fetching (the 5-item preview + schema generation)
  • Keep finishedAt and stats as optional fields (widget needs them)
  • Add storages and hint to response

4. Schema split

src/tools/structured_output_schemas.ts:

  • Add new actorRunOutputSchema for call-actor (base shape)
  • Add new getActorRunOutputSchema for get-actor-run (base + finishedAt/stats, minus old dataset)
  • Keep callActorOutputSchema and buildEnrichedCallActorOutputSchema unchanged — direct actor tools still use them

5. Widget compatibility

src/web/src/pages/ActorRun/ActorRun.tsx:

  • Update both get-actor-run calls (lines ~331 and ~394) to pass waitSecs: 0 for instant status checks
  • Remove dependency on dataset field from structuredContent (no more preview items from get-actor-run)
  • stats and finishedAt continue to work (kept in response)

6. Cancellation in task mode

src/tools/core/call_actor_common.ts — the waitForRunWithAbort() helper:

  • Races waitForFinish() against the abort signal
  • On abort: calls apifyClient.run(runId).abort() to stop the Actor on Apify
  • Returns null to indicate cancellation (caller returns empty response)

The abort signal chain: client cancels task → server's abortController.abort() → tool's abort signal fires → waitForRunWithAbort aborts the Apify run → task transitions to cancelled.

7. README update

Update README.md (~lines 208, 230) to reflect the new workflow:

  • Remove references to sync results / preview from call-actor
  • Document the start → wait → get-data pattern
  • Update tool disambiguation section

Internal repo impact

apify-mcp-server-internal does not import any of the changed functions (callActorGetDataset, buildActorResponseContent, callActorOutputSchema, getActorRunOutputSchema, fetchActorRunData). These are all internal implementation details not exported from index_internals.ts.

The internal server imports only: ActorsMcpServer, ApifyClient, HelperTools, getServerCard, tool registry functions, and utilities — none affected.

Internal integration tests exercise call-actor through the tool call layer and will see the new response shape. Assertion helpers in the internal test suite should be verified after the public package is updated.

No changes needed in internal repo, but verify after merging.

Breaking changes

Change Impact
call-actor no longer blocks and returns dataset preview Clients expecting dataset items in response will get run metadata instead
async parameter removed Clients passing async: true/false get validation error
previewOutput parameter removed Clients passing previewOutput get validation error
callActorOutputSchema shape unchanged Direct actor tools unaffected
get-actor-run drops dataset preview Clients expecting dataset field in response won't find it
get-actor-run adds waitSecs with default 10 Existing calls without waitSecs now wait up to 10s instead of returning instantly. Widget updated to pass waitSecs: 0.

These are acceptable per project constraints: breaking changes are OK when they produce a simpler, clearer implementation.

Testing strategy

Unit tests

  • Update tests/unit/tools.structured_output_schemas.test.ts — add tests for actorRunOutputSchema, keep buildEnrichedCallActorOutputSchema tests
  • Update tests/unit/tools.mode_contract.test.ts — description text changes
  • Test getHintForStatus() for all run statuses
  • Test buildCallActorStructuredContent() output format
  • Test waitForRunWithAbort() — abort signal cancels wait and aborts run

Integration tests (in tests/integration/suite.ts)

  • Update sync/async call-actor tests → assert new uniform shape with storages/hint
  • Delete previewOutput tests (param removed)
  • Delete preview items tests (no preview anymore)
  • Update task mode test → assert status: SUCCEEDED, hint contains "get-actor-output"
  • Update get-actor-run tests → validate against new schema, storages/hint
  • NEW: Test waitSecs on get-actor-run — start actor, wait with waitSecs: 30, assert terminal status
  • NEW: Test waitSecs: 0 returns immediately without waiting
  • Verify cancellation aborts the Actor run

Manual testing

  • Claude Desktop (stdio): verify LLM learns new workflow (call-actor → get-actor-run → get-actor-output)
  • MCPJam inspector: verify structuredContent format
  • ChatGPT (openai mode): verify widget still works — polls with waitSecs: 0, displays stats/finishedAt

Verification checklist

  • npm run type-check passes
  • npm run lint passes
  • npm run test:unit passes
  • Widget polling works with waitSecs: 0
  • Widget displays stats and finishedAt correctly
  • Task-mode cancellation aborts the Actor run
  • README updated
  • Internal repo impact assessed ✅ (no changes needed)
  • Breaking changes documented above

Follow-ups

Tracked in #587 (post-#582 roadmap):

  • Direct actor tools switching to the new response shape — Phase 2
  • get-actor-output accepting runId as alternative to datasetId — Phase 3 area

Metadata

Metadata

Assignees

Labels

medium priorityMedium priority issues to be done in a couple of sprints.t-aiIssues owned by the AI team.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions