From ec33d649f96933501b86c52e89290067ae7f5cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 07:25:18 +0000 Subject: [PATCH 01/14] fix: set final statusMessage before storeTaskResult (#638) storeTaskResult() has no statusMessage parameter, so the last intermediate progress message (e.g. "Starting the crawler.") persisted as the final statusMessage on completed/failed tasks. Call updateTaskStatus() with a descriptive final message right before each storeTaskResult() call. https://claude.ai/code/session_01ABYjPsZxtCdib54jfGcx3f --- src/mcp/server.ts | 5 ++ tests/unit/task.statusMessage.test.ts | 72 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/unit/task.statusMessage.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 051b3f08..48f55674 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1264,6 +1264,9 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); + // Set final statusMessage before transitioning to terminal state, + // because storeTaskResult does not accept a statusMessage parameter. + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: completed`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'completed', result, mcpSessionId); log.debug('Task completed successfully', { taskId, toolName: tool.name, mcpSessionId }); @@ -1273,6 +1276,7 @@ export class ActorsMcpServer { const httpStatus = getHttpStatusCode(error); if (httpStatus === HTTP_PAYMENT_REQUIRED) { logHttpError(error, 'Payment required while calling tool (task mode)', { toolName: tool.name }); + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: payment required`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'completed', buildPaymentRequiredResponse(error), mcpSessionId); finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, @@ -1337,6 +1341,7 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: failed`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'failed', { content: [{ type: 'text' as const, diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts new file mode 100644 index 00000000..1a86f5f4 --- /dev/null +++ b/tests/unit/task.statusMessage.test.ts @@ -0,0 +1,72 @@ +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'; +import { describe, expect, it } from 'vitest'; + +/** + * Regression tests for #638: tasks/list shows stale statusMessage for completed tasks. + * + * storeTaskResult() does not accept a statusMessage parameter, so we must call + * updateTaskStatus() with the final message before calling storeTaskResult(). + * These tests verify that statusMessage survives the storeTaskResult() transition. + */ +describe('Task statusMessage after terminal transition', () => { + const REQUEST_ID = 'req-1'; + const REQUEST = { method: 'tools/call', params: { name: 'test-tool' } }; + + async function createWorkingTask(store: InMemoryTaskStore) { + const task = await store.createTask({ ttl: 60_000 }, REQUEST_ID, REQUEST); + return task.taskId; + } + + it('should retain final statusMessage after storeTaskResult completes the task', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + // Intermediate progress message (as set by ProgressTracker during execution) + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: Starting the crawler.'); + + // Final message set just before storeTaskResult (the fix) + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: completed'); + + await store.storeTaskResult(taskId, 'completed', { + content: [{ type: 'text', text: 'result data' }], + }); + + const task = await store.getTask(taskId); + expect(task).not.toBeNull(); + expect(task!.status).toBe('completed'); + expect(task!.statusMessage).toBe('apify/rag-web-browser: completed'); + }); + + it('should retain final statusMessage after storeTaskResult marks task as failed', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + await store.updateTaskStatus(taskId, 'working', 'my-tool: failed'); + + await store.storeTaskResult(taskId, 'failed', { + content: [{ type: 'text', text: 'error' }], + isError: true, + }); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('failed'); + expect(task!.statusMessage).toBe('my-tool: failed'); + }); + + it('should show stale statusMessage when final updateTaskStatus is skipped (documents the bug)', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + await store.updateTaskStatus(taskId, 'working', 'Starting the crawler.'); + + // No final updateTaskStatus — this is the buggy path + await store.storeTaskResult(taskId, 'completed', { + content: [{ type: 'text', text: 'result data' }], + }); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('completed'); + // statusMessage is stale — still the intermediate progress message + expect(task!.statusMessage).toBe('Starting the crawler.'); + }); +}); From 87017c732c001b36c7193f7d6eda02a5da5f676f Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Wed, 15 Apr 2026 15:18:49 +0200 Subject: [PATCH 02/14] test: remove misleading third test case from task.statusMessage tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third test asserted that a stale intermediate statusMessage is preserved through storeTaskResult — which tests InMemoryTaskStore internals, not the bug. The two remaining cases are sufficient regression coverage. --- tests/unit/task.statusMessage.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index 1a86f5f4..e63ceca0 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -52,21 +52,4 @@ describe('Task statusMessage after terminal transition', () => { expect(task!.status).toBe('failed'); expect(task!.statusMessage).toBe('my-tool: failed'); }); - - it('should show stale statusMessage when final updateTaskStatus is skipped (documents the bug)', async () => { - const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); - - await store.updateTaskStatus(taskId, 'working', 'Starting the crawler.'); - - // No final updateTaskStatus — this is the buggy path - await store.storeTaskResult(taskId, 'completed', { - content: [{ type: 'text', text: 'result data' }], - }); - - const task = await store.getTask(taskId); - expect(task!.status).toBe('completed'); - // statusMessage is stale — still the intermediate progress message - expect(task!.statusMessage).toBe('Starting the crawler.'); - }); }); From 9a234abcabadd862295c64bc209c90e581f3b4ef Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 09:45:44 +0200 Subject: [PATCH 03/14] fix: ensure final statusMessage is set correctly during task result storage --- src/mcp/server.ts | 44 +++++++++------ src/mcp/utils.ts | 22 ++++++++ tests/integration/suite.ts | 36 +++++++++++++ tests/unit/task.statusMessage.test.ts | 77 +++++++++++++++++++++------ 4 files changed, 148 insertions(+), 31 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 48f55674..8e90e86d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -80,7 +80,7 @@ import { getUserIdFromTokenCached } from '../utils/userid_cache.js'; import { getPackageVersion } from '../utils/version.js'; import { connectMCPClient } from './client.js'; import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js'; -import { isTaskCancelled, processParamsGetTools } from './utils.js'; +import { isTaskCancelled, processParamsGetTools, storeTaskResultWithMessage } from './utils.js'; /** Mode → actor executor. Add new modes here. */ const actorExecutorsByMode: Record = { @@ -1259,16 +1259,21 @@ export class ActorsMcpServer { return; } - // Store the result in the task store - log.debug('[executeToolAndUpdateTask] Storing completed result', { - taskId, - mcpSessionId, - }); - // Set final statusMessage before transitioning to terminal state, - // because storeTaskResult does not accept a statusMessage parameter. - await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: completed`, mcpSessionId); - await this.taskStore.storeTaskResult(taskId, 'completed', result, mcpSessionId); - log.debug('Task completed successfully', { taskId, toolName: tool.name, mcpSessionId }); + // Set final statusMessage + store result. Three paths: + // - SUCCEEDED: normal completion + // - Pre-flight 402: stored as 'completed' because the x402 payment payload is a valid + // structured result that clients parse to pay (same as non-task mode which returns it directly) + // - ABORTED: signal-based abort (client disconnect / notifications/cancelled) — no result to store, + // transition to 'cancelled' to match tasks/cancel behavior + log.debug('[executeToolAndUpdateTask] Storing task result', { taskId, toolStatus, mcpSessionId }); + if (toolStatus === TOOL_STATUS.SUCCEEDED) { + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, `${getToolFullName(tool)}: completed`, mcpSessionId); + } else if (paymentRequiredResult) { + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, `${getToolFullName(tool)}: payment required`, mcpSessionId); + } else if (toolStatus === TOOL_STATUS.ABORTED) { + await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); + } + log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); finishTaskTracking(toolStatus, callDiagnostics); } catch (error) { @@ -1276,8 +1281,16 @@ export class ActorsMcpServer { const httpStatus = getHttpStatusCode(error); if (httpStatus === HTTP_PAYMENT_REQUIRED) { logHttpError(error, 'Payment required while calling tool (task mode)', { toolName: tool.name }); - await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: payment required`, mcpSessionId); - await this.taskStore.storeTaskResult(taskId, 'completed', buildPaymentRequiredResponse(error), mcpSessionId); + // Guard: task may have been cancelled while the actor was running. The SDK throws + // if we try to transition from terminal 'cancelled' to 'working' (inside storeTaskResultWithMessage). + // Track as ABORTED — the cancellation is what matters, not the 402. + if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { + finishTaskTracking(TOOL_STATUS.ABORTED, { ...buildActorFields(actorName, actorId) }); + return; + } + // Store as 'completed' — x402 payment payload is a valid result, not an error (see try-block comment) + const paymentResult = buildPaymentRequiredResponse(error); + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', paymentResult, `${getToolFullName(tool)}: payment required`, mcpSessionId); finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, failure_http_status: 402, @@ -1341,15 +1354,14 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); - await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: failed`, mcpSessionId); - await this.taskStore.storeTaskResult(taskId, 'failed', { + await storeTaskResultWithMessage(this.taskStore, taskId, 'failed', { content: [{ type: 'text' as const, text: userText, }], isError: true, internalToolStatus: toolStatus, - }, mcpSessionId); + }, `${getToolFullName(tool)}: failed`, mcpSessionId); finishTaskTracking(toolStatus, callDiagnostics); } diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 13c05551..7c8fc814 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -1,6 +1,7 @@ import { parse } from 'node:querystring'; import type { TaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/interfaces.js'; +import type { Result } from '@modelcontextprotocol/sdk/types.js'; import type { ApifyClient } from 'apify-client'; import { processInput } from '../input.js'; @@ -47,3 +48,24 @@ export async function isTaskCancelled( const task = await taskStore.getTask(taskId, mcpSessionId); return task?.status === 'cancelled'; } + +/** + * Stores a task result with a final statusMessage in one logical step. + * + * WARNING: This is NOT atomic. The SDK's storeTaskResult() does not accept a statusMessage, + * so we first call updateTaskStatus('working', message) then storeTaskResult(status, result). + * If the process crashes between the two calls, the task stays 'working' with the final message + * but never transitions to terminal state. This is acceptable because the same crash would leave + * the task stuck regardless — the TTL cleanup will eventually remove it. + */ +export async function storeTaskResultWithMessage( + taskStore: TaskStore, + taskId: string, + status: 'completed' | 'failed', + result: Result, + statusMessage: string, + sessionId?: string, +): Promise { + await taskStore.updateTaskStatus(taskId, 'working', statusMessage, sessionId); + await taskStore.storeTaskResult(taskId, status, result, sessionId); +} diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 437fb6a3..453701a1 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -2446,6 +2446,42 @@ export function createIntegrationTestsSuite( await assertStatusMessagePropagated(client, stream); }); + // Regression test for #638: final statusMessage must be ": completed" after task finishes, + // not a stale intermediate progress message like "Starting the crawler." + it('should set final statusMessage to "completed" after task finishes successfully', async () => { + client = await createClientFn({ tools: [ACTOR_PYTHON_EXAMPLE] }); + + const stream = client.experimental.tasks.callToolStream( + { + name: actorNameToToolName(ACTOR_PYTHON_EXAMPLE), + arguments: { + first_number: 7, + second_number: 8, + }, + }, + CallToolResultSchema, + { + task: { + ttl: 60000, + }, + }, + ); + + let taskId: string | null = null; + for await (const message of stream) { + if (message.type === 'taskCreated') { + taskId = message.task.taskId; + } else if (message.type === 'error') { + throw message.error; + } + } + + expect(taskId).not.toBeNull(); + const task = await client.experimental.tasks.getTask(taskId!); + expect(task.status).toBe('completed'); + expect(task.statusMessage).toMatch(/: completed$/); + }); + it.runIf(options.transport === 'stdio')('should use UI_MODE env var when CLI arg is not provided', async () => { client = await createClientFn({ useEnv: true, uiMode: 'openai' }); const tools = await client.listTools(); diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index e63ceca0..88c0d712 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -1,12 +1,18 @@ import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'; import { describe, expect, it } from 'vitest'; +import { storeTaskResultWithMessage } from '../../src/mcp/utils.js'; + /** * Regression tests for #638: tasks/list shows stale statusMessage for completed tasks. * * storeTaskResult() does not accept a statusMessage parameter, so we must call * updateTaskStatus() with the final message before calling storeTaskResult(). - * These tests verify that statusMessage survives the storeTaskResult() transition. + * + * These tests verify: + * 1. statusMessage survives the storeTaskResult() transition (SDK invariant) + * 2. The correct final message is set for each outcome (success, payment required, failed) + * 3. Non-success paths do NOT get a misleading ": completed" message */ describe('Task statusMessage after terminal transition', () => { const REQUEST_ID = 'req-1'; @@ -17,19 +23,13 @@ describe('Task statusMessage after terminal transition', () => { return task.taskId; } - it('should retain final statusMessage after storeTaskResult completes the task', async () => { + it('should set statusMessage and status atomically via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); const taskId = await createWorkingTask(store); - // Intermediate progress message (as set by ProgressTracker during execution) - await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: Starting the crawler.'); - - // Final message set just before storeTaskResult (the fix) - await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: completed'); - - await store.storeTaskResult(taskId, 'completed', { + await storeTaskResultWithMessage(store, taskId, 'completed', { content: [{ type: 'text', text: 'result data' }], - }); + }, 'apify/rag-web-browser: completed'); const task = await store.getTask(taskId); expect(task).not.toBeNull(); @@ -37,19 +37,66 @@ describe('Task statusMessage after terminal transition', () => { expect(task!.statusMessage).toBe('apify/rag-web-browser: completed'); }); - it('should retain final statusMessage after storeTaskResult marks task as failed', async () => { + it('should set statusMessage for failed tasks via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); const taskId = await createWorkingTask(store); - await store.updateTaskStatus(taskId, 'working', 'my-tool: failed'); - - await store.storeTaskResult(taskId, 'failed', { + await storeTaskResultWithMessage(store, taskId, 'failed', { content: [{ type: 'text', text: 'error' }], isError: true, - }); + }, 'my-tool: failed'); const task = await store.getTask(taskId); expect(task!.status).toBe('failed'); expect(task!.statusMessage).toBe('my-tool: failed'); }); + + it('should set "payment required" statusMessage for pre-flight 402 path, not "completed"', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + // Simulates the pre-flight payment failure path in executeToolAndUpdateTask: + // paymentRequiredResult is set, toolStatus = SOFT_FAIL, no actor execution happens. + // The fix guards the updateTaskStatus call so it stamps ": payment required" instead of ": completed". + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: payment required'); + + await store.storeTaskResult(taskId, 'completed', { + content: [{ type: 'text', text: 'Payment required' }], + }); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('completed'); + expect(task!.statusMessage).toBe('apify/rag-web-browser: payment required'); + expect(task!.statusMessage).not.toContain('completed'); + }); + + it('should set status "cancelled" when task was aborted via signal, not "completed"', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + // Simulates the signal-based abort path: executeActorTool returns null, + // toolStatus = ABORTED. The fix transitions to 'cancelled' instead of + // storing as 'completed' with empty result. + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: Running the crawler.'); + await store.updateTaskStatus(taskId, 'cancelled', 'apify/rag-web-browser: aborted by client'); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('cancelled'); + expect(task!.statusMessage).toBe('apify/rag-web-browser: aborted by client'); + }); + + it('should not call updateTaskStatus on cancelled task (402 catch path)', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + // Simulate: task is cancelled while actor is running + await store.updateTaskStatus(taskId, 'cancelled', 'Cancelled by client'); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('cancelled'); + + // Trying to transition from cancelled → working should throw (SDK enforces terminal state). + // The fix adds an isTaskCancelled guard before updateTaskStatus in the 402 catch path. + await expect(store.updateTaskStatus(taskId, 'working', 'some-tool: payment required')).rejects.toThrow(); + }); }); From 9917447ae7e8cf6e04840ff629c7d26c5d44caea Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 09:59:47 +0200 Subject: [PATCH 04/14] test: add unit tests for task execution handling in ActorsMcpServer --- src/mcp/server.ts | 22 ++++--- tests/unit/server.task_execution.test.ts | 73 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 tests/unit/server.task_execution.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8e90e86d..19147230 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1259,19 +1259,17 @@ export class ActorsMcpServer { return; } - // Set final statusMessage + store result. Three paths: - // - SUCCEEDED: normal completion - // - Pre-flight 402: stored as 'completed' because the x402 payment payload is a valid - // structured result that clients parse to pay (same as non-task mode which returns it directly) - // - ABORTED: signal-based abort (client disconnect / notifications/cancelled) — no result to store, - // transition to 'cancelled' to match tasks/cancel behavior - log.debug('[executeToolAndUpdateTask] Storing task result', { taskId, toolStatus, mcpSessionId }); - if (toolStatus === TOOL_STATUS.SUCCEEDED) { - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, `${getToolFullName(tool)}: completed`, mcpSessionId); - } else if (paymentRequiredResult) { - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, `${getToolFullName(tool)}: payment required`, mcpSessionId); - } else if (toolStatus === TOOL_STATUS.ABORTED) { + // Persist every non-aborted result. Task-supporting tools like call-actor can return + // isError/toolStatus without throwing, and task mode must store that payload the same + // way non-task mode returns it. + log.debug('Storing task result', { taskId, toolStatus, mcpSessionId }); + if (toolStatus === TOOL_STATUS.ABORTED) { await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); + } else { + const statusMessage = paymentRequiredResult + ? `${getToolFullName(tool)}: payment required` + : `${getToolFullName(tool)}: completed`; + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); } log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); diff --git a/tests/unit/server.task_execution.test.ts b/tests/unit/server.task_execution.test.ts new file mode 100644 index 00000000..307023ed --- /dev/null +++ b/tests/unit/server.task_execution.test.ts @@ -0,0 +1,73 @@ +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { FAILURE_CATEGORY, TOOL_STATUS } from '../../src/const.js'; +import { ActorsMcpServer } from '../../src/mcp/server.js'; +import type { ToolEntry } from '../../src/types.js'; + +describe('ActorsMcpServer task execution', () => { + it('stores non-throwing soft-fail results for task-supporting tools like call-actor', async () => { + const taskStore = new InMemoryTaskStore(); + const server = new ActorsMcpServer({ + taskStore, + telemetry: { enabled: false }, + transportType: 'stdio', + }); + + const task = await taskStore.createTask( + { ttl: 60_000 }, + 'req-1', + { method: 'tools/call', params: { name: 'soft-fail-tool' } }, + ); + + const tool = { + type: 'internal', + name: 'call-actor', + description: 'Call Actor', + inputSchema: { type: 'object', properties: {} }, + ajvValidate: vi.fn(), + execution: { + taskSupport: 'optional', + }, + call: vi.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'Actor \'missing/actor\' was not found.', + }], + isError: true, + toolTelemetry: { + toolStatus: TOOL_STATUS.SOFT_FAIL, + failureCategory: FAILURE_CATEGORY.INVALID_INPUT, + }, + }), + } as unknown as ToolEntry; + + await (server as unknown as { + executeToolAndUpdateTask: (params: Record) => Promise; + }).executeToolAndUpdateTask({ + taskId: task.taskId, + tool, + toolArgs: {}, + logSafeArgs: {}, + apifyClient: {} as never, + apifyToken: '', + progressToken: undefined, + extra: { + signal: new AbortController().signal, + }, + mcpSessionId: undefined, + }); + + const storedTask = await taskStore.getTask(task.taskId); + expect(storedTask?.status).toBe('completed'); + + const storedResult = await taskStore.getTaskResult(task.taskId); + expect(storedResult).toMatchObject({ + isError: true, + content: [{ + type: 'text', + text: 'Actor \'missing/actor\' was not found.', + }], + }); + }); +}); From 35c643d140ea8d0013e341539eda216457bf5486 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 10:08:52 +0200 Subject: [PATCH 05/14] fix: prevent updating task status to 'cancelled' if already terminal --- src/mcp/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 19147230..09a88259 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1264,7 +1264,11 @@ export class ActorsMcpServer { // way non-task mode returns it. log.debug('Storing task result', { taskId, toolStatus, mcpSessionId }); if (toolStatus === TOOL_STATUS.ABORTED) { - await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); + // Guard: a concurrent tasks/cancel request may have already transitioned the task + // to 'cancelled' between the isTaskCancelled check above and here. Skip if already terminal. + if (!await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { + await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); + } } else { const statusMessage = paymentRequiredResult ? `${getToolFullName(tool)}: payment required` From e3d12aaf68b891298046bc2200836bbe3778101c Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 11:12:46 +0200 Subject: [PATCH 06/14] fix: update cancellation message to include tool prefix if available --- src/mcp/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 09a88259..a68462dc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -603,7 +603,10 @@ export class ActorsMcpServer { `Cannot cancel task "${taskId}" with status "${task.status}"`, ); } - await this.taskStore.updateTaskStatus(taskId, 'cancelled', 'Cancelled by client', mcpSessionId); + // Extract tool/actor prefix from the last statusMessage (e.g. "apify/rag-web-browser: Starting...") + const toolPrefix = task.statusMessage?.split(':')?.[0]; + const cancelMessage = toolPrefix ? `${toolPrefix}: cancelled by client` : 'Cancelled by client'; + await this.taskStore.updateTaskStatus(taskId, 'cancelled', cancelMessage, mcpSessionId); const updatedTask = await this.taskStore.getTask(taskId, mcpSessionId); log.debug('[CancelTaskRequestSchema] Task cancelled successfully', { taskId, mcpSessionId }); return updatedTask!; From 77c3bb65a2c68c684a09abadbf8c69962c1a1923 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 11:32:25 +0200 Subject: [PATCH 07/14] fix: update task status handling to correctly reflect 'failed' state --- src/mcp/server.ts | 23 ++++++++++++++++------- tests/unit/server.task_execution.test.ts | 2 +- tests/unit/task.statusMessage.test.ts | 4 ++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a68462dc..98864fea 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1273,10 +1273,20 @@ export class ActorsMcpServer { await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); } } else { - const statusMessage = paymentRequiredResult - ? `${getToolFullName(tool)}: payment required` - : `${getToolFullName(tool)}: completed`; - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); + const isError = toolStatus === TOOL_STATUS.SOFT_FAIL || paymentRequiredResult; + const taskStatus = isError ? 'failed' : 'completed'; + let statusMessage: string; + if (paymentRequiredResult) { + statusMessage = `${getToolFullName(tool)}: payment required`; + } else if (toolStatus === TOOL_STATUS.SOFT_FAIL) { + const errorText = (result as { content?: { text?: string }[] })?.content?.[0]?.text?.slice(0, 200) || ''; + statusMessage = errorText + ? `${getToolFullName(tool)}: ${errorText}` + : `${getToolFullName(tool)}: failed`; + } else { + statusMessage = `${getToolFullName(tool)}: completed`; + } + await storeTaskResultWithMessage(this.taskStore, taskId, taskStatus, result, statusMessage, mcpSessionId); } log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); @@ -1293,9 +1303,8 @@ export class ActorsMcpServer { finishTaskTracking(TOOL_STATUS.ABORTED, { ...buildActorFields(actorName, actorId) }); return; } - // Store as 'completed' — x402 payment payload is a valid result, not an error (see try-block comment) const paymentResult = buildPaymentRequiredResponse(error); - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', paymentResult, `${getToolFullName(tool)}: payment required`, mcpSessionId); + await storeTaskResultWithMessage(this.taskStore, taskId, 'failed', paymentResult, `${getToolFullName(tool)}: payment required`, mcpSessionId); finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, failure_http_status: 402, @@ -1366,7 +1375,7 @@ export class ActorsMcpServer { }], isError: true, internalToolStatus: toolStatus, - }, `${getToolFullName(tool)}: failed`, mcpSessionId); + }, `${getToolFullName(tool)}: ${userText.slice(0, 200) || 'failed'}`, mcpSessionId); finishTaskTracking(toolStatus, callDiagnostics); } diff --git a/tests/unit/server.task_execution.test.ts b/tests/unit/server.task_execution.test.ts index 307023ed..93b2e558 100644 --- a/tests/unit/server.task_execution.test.ts +++ b/tests/unit/server.task_execution.test.ts @@ -59,7 +59,7 @@ describe('ActorsMcpServer task execution', () => { }); const storedTask = await taskStore.getTask(task.taskId); - expect(storedTask?.status).toBe('completed'); + expect(storedTask?.status).toBe('failed'); const storedResult = await taskStore.getTaskResult(task.taskId); expect(storedResult).toMatchObject({ diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index 88c0d712..83f65dd4 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -60,12 +60,12 @@ describe('Task statusMessage after terminal transition', () => { // The fix guards the updateTaskStatus call so it stamps ": payment required" instead of ": completed". await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: payment required'); - await store.storeTaskResult(taskId, 'completed', { + await store.storeTaskResult(taskId, 'failed', { content: [{ type: 'text', text: 'Payment required' }], }); const task = await store.getTask(taskId); - expect(task!.status).toBe('completed'); + expect(task!.status).toBe('failed'); expect(task!.statusMessage).toBe('apify/rag-web-browser: payment required'); expect(task!.statusMessage).not.toContain('completed'); }); From 4d95f05e00e8f6030891659500ba78f8d64bb597 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 12:22:10 +0200 Subject: [PATCH 08/14] fix: store all task results as 'completed' to work around SDK limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP SDK's requestStream() only calls getTaskResult() for 'completed' tasks — 'failed' tasks yield a generic "Task X failed" error and discard the stored result. This broke x402 auto-pay retry (client never saw the payment payload) and lost error details for soft-fail results. - Revert task status from 'failed' back to 'completed' for all result paths - Add [error] prefix to statusMessage so tasks/list shows the real outcome - Add "… (truncated)" suffix when statusMessage is clipped to 200 chars - Document the workaround and upstream fixes needed in res/task_status_workaround.md Co-Authored-By: Claude Opus 4.6 (1M context) --- res/index.md | 7 ++ res/task_status_workaround.md | 97 ++++++++++++++++++++++++ src/mcp/server.ts | 28 ++++--- tests/unit/server.task_execution.test.ts | 4 +- tests/unit/task.statusMessage.test.ts | 30 ++++---- 5 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 res/task_status_workaround.md diff --git a/res/index.md b/res/index.md index a7f79ba0..1a50e192 100644 --- a/res/index.md +++ b/res/index.md @@ -76,6 +76,13 @@ Apify input-schema spec semantics (required vs default vs prefill), the root cau Notes on keeping widget bundles small (narrow `@apify/ui-library` imports, markdown stack cost). - **Use case**: When changing widget dependencies or markdown rendering, re-measure bundle impact +### [task_status_workaround.md](./task_status_workaround.md) +Why all task results (including errors) are stored as `'completed'` instead of `'failed'`. +- SDK `requestStream()` discards stored results for `'failed'` tasks +- `[error]` prefix convention in `statusMessage` to signal the real outcome +- Upstream fixes needed in the MCP SDK and mcpc +- **Use case**: Reference when touching task status logic or updating the MCP SDK + ### [chatgpt-app-submission.md](./chatgpt-app-submission.md) Checklist and notes for ChatGPT MCP Apps store submission (verify line references against current source before relying on them). - **Use case**: Submission prep and audits diff --git a/res/task_status_workaround.md b/res/task_status_workaround.md new file mode 100644 index 00000000..cfffd8fb --- /dev/null +++ b/res/task_status_workaround.md @@ -0,0 +1,97 @@ +# Task status workaround: why errors are stored as 'completed' + +## The problem + +The MCP SDK's `requestStream()` (in `shared/protocol.js`) handles terminal task states differently: + +- **`'completed'`** → calls `getTaskResult()` → yields `{ type: 'result', result }` → client gets the full `CallToolResult` +- **`'failed'`** → yields `{ type: 'error', error: "Task {id} failed" }` → client gets a generic error, **stored result is discarded** +- **`'cancelled'`** → same as failed, generic error + +This means any result stored with `status: 'failed'` is never delivered to the client. The actual error text, x402 payment payload, structured content — all lost. + +## What we do + +We store **all** task results as `'completed'`, including errors. The error nature is conveyed through: + +1. **`isError: true`** in the `CallToolResult` payload — this is what clients (mcpc, Claude, Cursor) use to determine success/failure +2. **`[error]` prefix** in `statusMessage` — so `tasks/list` and `tasks/get` polling clearly shows the task failed + +### Affected paths in `executeToolAndUpdateTask()` + +| Path | Result | Status | statusMessage | +|---|---|---|---| +| Success | tool output | `'completed'` | `tool-name: completed` | +| SOFT_FAIL (actor not found, validation) | error text + `isError: true` | `'completed'` | `[error] tool-name: Actor not found...` | +| Payment required (pre-flight) | x402 payload + `isError: true` | `'completed'` | `[error] tool-name: payment required` | +| Payment required (catch 402) | x402 payload + `isError: true` | `'completed'` | `[error] tool-name: payment required` | +| Hard error (5xx, network, etc.) | error text + `isError: true` | `'completed'` | `[error] tool-name: error text...` | +| Aborted (signal) | none | `'cancelled'` | `tool-name: aborted by client` | + +### Why x402 payment specifically requires 'completed' + +The mcpc bridge's `handlePaymentRequiredRetry()` inspects the tool result for `isError: true` + `x402Version` + `accepts` fields. If the task is stored as `'failed'`, the SDK never delivers the result, and the auto-pay retry never triggers. + +## What should be fixed upstream + +### SDK: `requestStream()` should deliver results for 'failed' tasks + +Location: `@modelcontextprotocol/sdk/shared/protocol.js`, lines ~566-583 + +```javascript +// Current behavior (broken): +if (task.status === 'failed') { + yield { type: 'error', error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`) }; +} + +// Desired behavior: +if (task.status === 'failed') { + const result = await this.getTaskResult({ taskId }, resultSchema, options); + yield { type: 'result', result }; // result has isError: true +} +``` + +The `'failed'` status was stored alongside a result via `storeTaskResult()`. The SDK should deliver that result, not discard it. The `isError` flag in the result already tells the client it's an error. + +Alternatively, `requestStream()` could yield both the result and a status indicator, but the simplest fix is to treat `'failed'` the same as `'completed'` for result delivery. + +### SDK: `requestStream()` 'failed' error should include statusMessage + +Even without delivering the full result, the generic `"Task {id} failed"` message should at least include `task.statusMessage`: + +```javascript +yield { + type: 'error', + error: new McpError(ErrorCode.InternalError, task.statusMessage || `Task ${taskId} failed`) +}; +``` + +### SDK: `storeTaskResult()` should accept a statusMessage + +Currently we use `storeTaskResultWithMessage()` which calls `updateTaskStatus('working', message)` then `storeTaskResult(status, result)` — two non-atomic calls. The SDK's `storeTaskResult()` should accept an optional `statusMessage` parameter to make this atomic. + +### mcpc: `pollTask()` should fetch result for 'completed' tasks + +Location: `mcp-cli/src/core/mcp-client.ts`, lines ~715-720 + +```javascript +// Current behavior (incomplete): +if (task.status === 'completed') { + return { content: [{ type: 'text', text: task.statusMessage || 'Task completed' }] }; +} + +// Desired behavior: +if (task.status === 'completed') { + const result = await this.getTaskResult(taskId); + return result; +} +``` + +The polling fallback returns the `statusMessage` as fake content instead of fetching the actual stored result. + +## When to remove this workaround + +Once the SDK's `requestStream()` delivers results for `'failed'` tasks, we can: +1. Store errors as `'failed'` (semantically correct) +2. Remove the `[error]` prefix from statusMessages +3. Remove `storeTaskResultWithMessage()` if `storeTaskResult()` accepts statusMessage diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 98864fea..a71223ae 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1273,20 +1273,24 @@ export class ActorsMcpServer { await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); } } else { - const isError = toolStatus === TOOL_STATUS.SOFT_FAIL || paymentRequiredResult; - const taskStatus = isError ? 'failed' : 'completed'; + // Always store as 'completed' — even for SOFT_FAIL and paymentRequired. + // The MCP SDK's requestStream() only calls getTaskResult() for 'completed' tasks; + // 'failed' tasks yield a generic "Task X failed" error and discard the stored result. + // Error details are preserved in the result payload (isError: true, content text). + // See res/task_status_workaround.md for full context. let statusMessage: string; if (paymentRequiredResult) { - statusMessage = `${getToolFullName(tool)}: payment required`; + statusMessage = `[error] ${getToolFullName(tool)}: payment required`; } else if (toolStatus === TOOL_STATUS.SOFT_FAIL) { - const errorText = (result as { content?: { text?: string }[] })?.content?.[0]?.text?.slice(0, 200) || ''; + const fullText = (result as { content?: { text?: string }[] })?.content?.[0]?.text || ''; + const errorText = fullText.length > 200 ? `${fullText.slice(0, 200)}… (truncated)` : fullText; statusMessage = errorText - ? `${getToolFullName(tool)}: ${errorText}` - : `${getToolFullName(tool)}: failed`; + ? `[error] ${getToolFullName(tool)}: ${errorText}` + : `[error] ${getToolFullName(tool)}: failed`; } else { statusMessage = `${getToolFullName(tool)}: completed`; } - await storeTaskResultWithMessage(this.taskStore, taskId, taskStatus, result, statusMessage, mcpSessionId); + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); } log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); @@ -1303,8 +1307,10 @@ export class ActorsMcpServer { finishTaskTracking(TOOL_STATUS.ABORTED, { ...buildActorFields(actorName, actorId) }); return; } + // Store as 'completed' — see comment above and res/task_status_workaround.md. + // The x402 payload must reach the client for auto-pay retry. const paymentResult = buildPaymentRequiredResponse(error); - await storeTaskResultWithMessage(this.taskStore, taskId, 'failed', paymentResult, `${getToolFullName(tool)}: payment required`, mcpSessionId); + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', paymentResult, `[error] ${getToolFullName(tool)}: payment required`, mcpSessionId); finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, failure_http_status: 402, @@ -1368,14 +1374,16 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); - await storeTaskResultWithMessage(this.taskStore, taskId, 'failed', { + // Store as 'completed' so the SDK's requestStream() delivers the result to the client. + // See res/task_status_workaround.md for full context. + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', { content: [{ type: 'text' as const, text: userText, }], isError: true, internalToolStatus: toolStatus, - }, `${getToolFullName(tool)}: ${userText.slice(0, 200) || 'failed'}`, mcpSessionId); + }, `[error] ${getToolFullName(tool)}: ${userText.length > 200 ? `${userText.slice(0, 200)}… (truncated)` : userText || 'failed'}`, mcpSessionId); finishTaskTracking(toolStatus, callDiagnostics); } diff --git a/tests/unit/server.task_execution.test.ts b/tests/unit/server.task_execution.test.ts index 93b2e558..aaa58207 100644 --- a/tests/unit/server.task_execution.test.ts +++ b/tests/unit/server.task_execution.test.ts @@ -58,8 +58,10 @@ describe('ActorsMcpServer task execution', () => { mcpSessionId: undefined, }); + // Stored as 'completed' because the SDK's requestStream() only delivers getTaskResult() + // for 'completed' tasks. Error details are in the result payload (isError: true). const storedTask = await taskStore.getTask(task.taskId); - expect(storedTask?.status).toBe('failed'); + expect(storedTask?.status).toBe('completed'); const storedResult = await taskStore.getTaskResult(task.taskId); expect(storedResult).toMatchObject({ diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index 83f65dd4..3e63f1b0 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -37,37 +37,39 @@ describe('Task statusMessage after terminal transition', () => { expect(task!.statusMessage).toBe('apify/rag-web-browser: completed'); }); - it('should set statusMessage for failed tasks via storeTaskResultWithMessage', async () => { + it('should set statusMessage for error tasks via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); const taskId = await createWorkingTask(store); - await storeTaskResultWithMessage(store, taskId, 'failed', { + // Error results are stored as 'completed' because the SDK's requestStream() only + // delivers getTaskResult() for 'completed' tasks. The [error] prefix signals the + // real outcome to clients polling tasks/list. + await storeTaskResultWithMessage(store, taskId, 'completed', { content: [{ type: 'text', text: 'error' }], isError: true, - }, 'my-tool: failed'); + }, '[error] my-tool: failed'); const task = await store.getTask(taskId); - expect(task!.status).toBe('failed'); - expect(task!.statusMessage).toBe('my-tool: failed'); + expect(task!.status).toBe('completed'); + expect(task!.statusMessage).toBe('[error] my-tool: failed'); }); - it('should set "payment required" statusMessage for pre-flight 402 path, not "completed"', async () => { + it('should set "payment required" statusMessage for pre-flight 402 path', async () => { const store = new InMemoryTaskStore(); const taskId = await createWorkingTask(store); - // Simulates the pre-flight payment failure path in executeToolAndUpdateTask: - // paymentRequiredResult is set, toolStatus = SOFT_FAIL, no actor execution happens. - // The fix guards the updateTaskStatus call so it stamps ": payment required" instead of ": completed". - await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: payment required'); + // Simulates the pre-flight payment failure path in executeToolAndUpdateTask. + // Stored as 'completed' so the SDK's requestStream() delivers the x402 payload + // to the client for auto-pay retry. The [error] prefix signals the real outcome. + await store.updateTaskStatus(taskId, 'working', '[error] apify/rag-web-browser: payment required'); - await store.storeTaskResult(taskId, 'failed', { + await store.storeTaskResult(taskId, 'completed', { content: [{ type: 'text', text: 'Payment required' }], }); const task = await store.getTask(taskId); - expect(task!.status).toBe('failed'); - expect(task!.statusMessage).toBe('apify/rag-web-browser: payment required'); - expect(task!.statusMessage).not.toContain('completed'); + expect(task!.status).toBe('completed'); + expect(task!.statusMessage).toBe('[error] apify/rag-web-browser: payment required'); }); it('should set status "cancelled" when task was aborted via signal, not "completed"', async () => { From ef827faf62de5204adad6f55e6b4b20db5cb2372 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 13:40:18 +0200 Subject: [PATCH 09/14] fix: enhance task result handling for x402 payment errors and improve logging --- res/task_status_workaround.md | 16 +++++++++-- src/dev_server.ts | 2 ++ tests/integration/suite.ts | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/res/task_status_workaround.md b/res/task_status_workaround.md index cfffd8fb..d51a9c21 100644 --- a/res/task_status_workaround.md +++ b/res/task_status_workaround.md @@ -72,10 +72,15 @@ Currently we use `storeTaskResultWithMessage()` which calls `updateTaskStatus('w ### mcpc: `pollTask()` should fetch result for 'completed' tasks +**Scope:** `pollTask()` is the detached task polling fallback, used when a task was started via +`callToolDetached()` and polled manually later. The normal `callTool()` path is **not affected** — +it uses `callToolStream()` → SDK `requestStream()` → correctly calls `getTaskResult()` for +`'completed'` tasks. + Location: `mcp-cli/src/core/mcp-client.ts`, lines ~715-720 ```javascript -// Current behavior (incomplete): +// Current behavior (incomplete — detached polling only): if (task.status === 'completed') { return { content: [{ type: 'text', text: task.statusMessage || 'Task completed' }] }; } @@ -87,7 +92,14 @@ if (task.status === 'completed') { } ``` -The polling fallback returns the `statusMessage` as fake content instead of fetching the actual stored result. +The detached polling fallback returns `statusMessage` as fake content instead of fetching the actual +stored result via `tasks/result`. The actual tool output (dataset items, actor run info, etc.) is lost. + +**Normal mcpc path is correct:** +``` +mcpc callTool → callToolStream → requestStream → completed → getTaskResult ✅ +mcpc pollTask → tasks/get loop → completed → statusMessage as content ❌ (detached only) +``` ## When to remove this workaround diff --git a/src/dev_server.ts b/src/dev_server.ts index cfd02f80..0452586b 100644 --- a/src/dev_server.ts +++ b/src/dev_server.ts @@ -317,6 +317,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { process.exit(1); } + log.setLevel(process.env.LOG_LEVEL === 'info' ? log.LEVELS.INFO : log.LEVELS.DEBUG); + const HOST = process.env.HOST ?? 'http://localhost'; const PORT = Number(process.env.PORT ?? 3001); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index 453701a1..179c45b3 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -2627,6 +2627,60 @@ export function createIntegrationTestsSuite( }, ); + // x402 payment mode only works with Streamable-HTTP transport (requires HTTP headers). + // This test verifies PR #680: payment-required errors are stored as 'completed' (not 'failed') + // so the SDK's requestStream() delivers the x402 payload to the client for auto-pay retry. + it.runIf(options.transport === 'streamable-http')( + 'should return x402 payment error via task mode when calling paymentRequired tool without payment signature', + async () => { + client = await createClientFn({ tools: ['actors'], payment: 'x402' }); + + const stream = client.experimental.tasks.callToolStream( + { + name: HelperTools.ACTOR_CALL, + arguments: { + actor: ACTOR_PYTHON_EXAMPLE, + input: { first_number: 1, second_number: 2 }, + }, + }, + CallToolResultSchema, + { task: { ttl: 60000 } }, + ); + + let taskCreated = false; + let resultReceived = false; + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': + taskCreated = true; + expect(message.task.taskId).toBeDefined(); + break; + case 'taskStatus': + expect(['working', 'completed']).toContain(message.task.status); + break; + case 'result': { + // x402 payment payload must reach the client — not a generic "Task X failed" + expect(message.result.isError).toBe(true); + const content = message.result.content as { text: string }[]; + expect(content[0].text).toContain('x402'); + resultReceived = true; + break; + } + case 'error': + // Must NOT throw — the PR guarantees task result is delivered, not discarded + throw new Error(`Expected structured result delivery but got error: ${message.error.message}`); + default: + throw new Error(`Unknown message type: ${(message as unknown as { type: string }).type}`); + } + } + + expect(taskCreated).toBe(true); + expect(resultReceived).toBe(true); + + await client.close(); + }, + ); + it('should return required structuredContent fields for ActorRun widget (get-actor-run)', async () => { client = await createClientFn({ tools: ['actors', 'runs'] }); From 5c87cfe019decd9daedea77854a384ce5acc3da6 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 13:52:38 +0200 Subject: [PATCH 10/14] fix: normalize cancellation messages and handle task result storage errors --- src/mcp/server.ts | 54 ++++++++++++++++++++++----- tests/unit/task.statusMessage.test.ts | 2 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a71223ae..3754a996 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -604,7 +604,9 @@ export class ActorsMcpServer { ); } // Extract tool/actor prefix from the last statusMessage (e.g. "apify/rag-web-browser: Starting...") - const toolPrefix = task.statusMessage?.split(':')?.[0]; + // Strip leading "[error] " so cancellation doesn't inherit the error marker. + const normalizedMessage = task.statusMessage?.replace(/^\[error\]\s*/i, '').trim(); + const toolPrefix = normalizedMessage?.split(':')?.[0]; const cancelMessage = toolPrefix ? `${toolPrefix}: cancelled by client` : 'Cancelled by client'; await this.taskStore.updateTaskStatus(taskId, 'cancelled', cancelMessage, mcpSessionId); const updatedTask = await this.taskStore.getTask(taskId, mcpSessionId); @@ -1290,7 +1292,23 @@ export class ActorsMcpServer { } else { statusMessage = `${getToolFullName(tool)}: completed`; } - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); + try { + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); + } catch (storeError) { + if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { + log.debug('[executeToolAndUpdateTask] Task was cancelled during result storage, treating as aborted', { + taskId, + mcpSessionId, + }); + finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); + return; + } + log.error('[executeToolAndUpdateTask] Failed to store task result', { + taskId, + mcpSessionId, + error: storeError, + }); + } } log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); @@ -1376,14 +1394,30 @@ export class ActorsMcpServer { }); // Store as 'completed' so the SDK's requestStream() delivers the result to the client. // See res/task_status_workaround.md for full context. - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', { - content: [{ - type: 'text' as const, - text: userText, - }], - isError: true, - internalToolStatus: toolStatus, - }, `[error] ${getToolFullName(tool)}: ${userText.length > 200 ? `${userText.slice(0, 200)}… (truncated)` : userText || 'failed'}`, mcpSessionId); + try { + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', { + content: [{ + type: 'text' as const, + text: userText, + }], + isError: true, + internalToolStatus: toolStatus, + }, `[error] ${getToolFullName(tool)}: ${userText.length > 200 ? `${userText.slice(0, 200)}… (truncated)` : userText || 'failed'}`, mcpSessionId); + } catch (storeError) { + if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { + log.debug('[executeToolAndUpdateTask] Task was cancelled during error result storage, treating as aborted', { + taskId, + mcpSessionId, + }); + finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); + return; + } + log.error('[executeToolAndUpdateTask] Failed to store error result', { + taskId, + mcpSessionId, + error: storeError, + }); + } finishTaskTracking(toolStatus, callDiagnostics); } diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index 3e63f1b0..514b9851 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -23,7 +23,7 @@ describe('Task statusMessage after terminal transition', () => { return task.taskId; } - it('should set statusMessage and status atomically via storeTaskResultWithMessage', async () => { + it('should set statusMessage and status via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); const taskId = await createWorkingTask(store); From 8805dec233967f42e72b6c2b7f92d227bcfc3f0f Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 14:13:28 +0200 Subject: [PATCH 11/14] fix: improve task result storage with cancellation handling and error messaging --- src/mcp/server.ts | 104 ++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3754a996..e933ae7d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,7 +10,7 @@ import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { InitializeRequest, Notification, Request } from '@modelcontextprotocol/sdk/types.js'; +import type { InitializeRequest, Notification, Request, Result } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, CallToolResultSchema, @@ -1134,9 +1134,11 @@ export class ActorsMcpServer { mcpSessionId, }); + const toolFullName = getToolFullName(tool); + // Prepare telemetry before try-catch so it's accessible to both paths. // This avoids re-fetching user data in the error handler. - const { telemetryData, userId } = await this.prepareTelemetryData(getToolFullName(tool), mcpSessionId, apifyToken); + const { telemetryData, userId } = await this.prepareTelemetryData(toolFullName, mcpSessionId, apifyToken); const finishTaskTracking = (status: ToolStatus, diagnostics?: CallDiagnostics) => { this.logToolCallAndTelemetry({ @@ -1150,6 +1152,33 @@ export class ActorsMcpServer { callDiagnostics: diagnostics, }); }; + const errorMsg = (detail: string) => `[error] ${toolFullName}: ${detail}`; + const truncate = (text: string, fallback = 'failed') => { + if (!text) return fallback; + return text.length > 200 ? `${text.slice(0, 200)}… (truncated)` : text; + }; + + // Stores a task result with cancellation-race guard. If the task was concurrently + // cancelled, storeTaskResultWithMessage throws (terminal state can't transition to 'working'). + // Returns false if cancelled (tracking already done), true otherwise. + const safeStoreResult = async (result: Result, statusMessage: string): Promise => { + try { + await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); + return true; + } catch (storeError) { + if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { + log.debug('[executeToolAndUpdateTask] Task was cancelled during result storage, treating as aborted', { + taskId, mcpSessionId, + }); + finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); + return false; + } + log.error('[executeToolAndUpdateTask] Failed to store task result', { + taskId, mcpSessionId, error: storeError, + }); + return true; + } + }; try { // Check if task was already cancelled before we start execution. @@ -1272,7 +1301,7 @@ export class ActorsMcpServer { // Guard: a concurrent tasks/cancel request may have already transitioned the task // to 'cancelled' between the isTaskCancelled check above and here. Skip if already terminal. if (!await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { - await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${getToolFullName(tool)}: aborted by client`, mcpSessionId); + await this.taskStore.updateTaskStatus(taskId, 'cancelled', `${toolFullName}: aborted by client`, mcpSessionId); } } else { // Always store as 'completed' — even for SOFT_FAIL and paymentRequired. @@ -1282,33 +1311,14 @@ export class ActorsMcpServer { // See res/task_status_workaround.md for full context. let statusMessage: string; if (paymentRequiredResult) { - statusMessage = `[error] ${getToolFullName(tool)}: payment required`; + statusMessage = errorMsg('payment required'); } else if (toolStatus === TOOL_STATUS.SOFT_FAIL) { const fullText = (result as { content?: { text?: string }[] })?.content?.[0]?.text || ''; - const errorText = fullText.length > 200 ? `${fullText.slice(0, 200)}… (truncated)` : fullText; - statusMessage = errorText - ? `[error] ${getToolFullName(tool)}: ${errorText}` - : `[error] ${getToolFullName(tool)}: failed`; + statusMessage = errorMsg(truncate(fullText)); } else { - statusMessage = `${getToolFullName(tool)}: completed`; - } - try { - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', result, statusMessage, mcpSessionId); - } catch (storeError) { - if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { - log.debug('[executeToolAndUpdateTask] Task was cancelled during result storage, treating as aborted', { - taskId, - mcpSessionId, - }); - finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); - return; - } - log.error('[executeToolAndUpdateTask] Failed to store task result', { - taskId, - mcpSessionId, - error: storeError, - }); + statusMessage = `${toolFullName}: completed`; } + if (!await safeStoreResult(result, statusMessage)) return; } log.debug('Task execution finished', { taskId, toolName: tool.name, toolStatus, mcpSessionId }); @@ -1318,17 +1328,14 @@ export class ActorsMcpServer { const httpStatus = getHttpStatusCode(error); if (httpStatus === HTTP_PAYMENT_REQUIRED) { logHttpError(error, 'Payment required while calling tool (task mode)', { toolName: tool.name }); - // Guard: task may have been cancelled while the actor was running. The SDK throws - // if we try to transition from terminal 'cancelled' to 'working' (inside storeTaskResultWithMessage). - // Track as ABORTED — the cancellation is what matters, not the 402. + // Guard: task may have been cancelled while the actor was running. if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { finishTaskTracking(TOOL_STATUS.ABORTED, { ...buildActorFields(actorName, actorId) }); return; } - // Store as 'completed' — see comment above and res/task_status_workaround.md. - // The x402 payload must reach the client for auto-pay retry. + // Store as 'completed' — see comment in the try block and res/task_status_workaround.md. const paymentResult = buildPaymentRequiredResponse(error); - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', paymentResult, `[error] ${getToolFullName(tool)}: payment required`, mcpSessionId); + if (!await safeStoreResult(paymentResult, errorMsg('payment required'))) return; finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, failure_http_status: 402, @@ -1388,36 +1395,13 @@ export class ActorsMcpServer { return; } - log.debug('[executeToolAndUpdateTask] Storing failed result', { - taskId, - mcpSessionId, - }); // Store as 'completed' so the SDK's requestStream() delivers the result to the client. // See res/task_status_workaround.md for full context. - try { - await storeTaskResultWithMessage(this.taskStore, taskId, 'completed', { - content: [{ - type: 'text' as const, - text: userText, - }], - isError: true, - internalToolStatus: toolStatus, - }, `[error] ${getToolFullName(tool)}: ${userText.length > 200 ? `${userText.slice(0, 200)}… (truncated)` : userText || 'failed'}`, mcpSessionId); - } catch (storeError) { - if (await isTaskCancelled(taskId, mcpSessionId, this.taskStore)) { - log.debug('[executeToolAndUpdateTask] Task was cancelled during error result storage, treating as aborted', { - taskId, - mcpSessionId, - }); - finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); - return; - } - log.error('[executeToolAndUpdateTask] Failed to store error result', { - taskId, - mcpSessionId, - error: storeError, - }); - } + if (!await safeStoreResult({ + content: [{ type: 'text' as const, text: userText }], + isError: true, + internalToolStatus: toolStatus, + }, errorMsg(truncate(userText)))) return; finishTaskTracking(toolStatus, callDiagnostics); } From 558070b5c49b27ae1b28bf08a31ecfd2b8d42106 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:30:19 +0000 Subject: [PATCH 12/14] fix: apply follow-up review thread changes Agent-Logs-Url: https://github.com/apify/apify-mcp-server/sessions/b6debe5d-50b7-4b92-9b85-8ab365c2df09 Co-authored-by: jirispilka <19406805+jirispilka@users.noreply.github.com> --- src/mcp/server.ts | 5 +---- src/mcp/utils.ts | 2 +- tests/unit/server.task_execution.test.ts | 2 +- tests/unit/task.statusMessage.test.ts | 12 ++++++------ 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e933ae7d..b49f7aa0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1173,10 +1173,7 @@ export class ActorsMcpServer { finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); return false; } - log.error('[executeToolAndUpdateTask] Failed to store task result', { - taskId, mcpSessionId, error: storeError, - }); - return true; + throw storeError; } }; diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 7c8fc814..01ad7020 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -61,7 +61,7 @@ export async function isTaskCancelled( export async function storeTaskResultWithMessage( taskStore: TaskStore, taskId: string, - status: 'completed' | 'failed', + status: 'completed', result: Result, statusMessage: string, sessionId?: string, diff --git a/tests/unit/server.task_execution.test.ts b/tests/unit/server.task_execution.test.ts index aaa58207..38ab33cc 100644 --- a/tests/unit/server.task_execution.test.ts +++ b/tests/unit/server.task_execution.test.ts @@ -17,7 +17,7 @@ describe('ActorsMcpServer task execution', () => { const task = await taskStore.createTask( { ttl: 60_000 }, 'req-1', - { method: 'tools/call', params: { name: 'soft-fail-tool' } }, + { method: 'tools/call', params: { name: 'call-actor' } }, ); const tool = { diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts index 514b9851..02ca60aa 100644 --- a/tests/unit/task.statusMessage.test.ts +++ b/tests/unit/task.statusMessage.test.ts @@ -18,14 +18,14 @@ describe('Task statusMessage after terminal transition', () => { const REQUEST_ID = 'req-1'; const REQUEST = { method: 'tools/call', params: { name: 'test-tool' } }; - async function createWorkingTask(store: InMemoryTaskStore) { + async function createTaskId(store: InMemoryTaskStore) { const task = await store.createTask({ ttl: 60_000 }, REQUEST_ID, REQUEST); return task.taskId; } it('should set statusMessage and status via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); + const taskId = await createTaskId(store); await storeTaskResultWithMessage(store, taskId, 'completed', { content: [{ type: 'text', text: 'result data' }], @@ -39,7 +39,7 @@ describe('Task statusMessage after terminal transition', () => { it('should set statusMessage for error tasks via storeTaskResultWithMessage', async () => { const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); + const taskId = await createTaskId(store); // Error results are stored as 'completed' because the SDK's requestStream() only // delivers getTaskResult() for 'completed' tasks. The [error] prefix signals the @@ -56,7 +56,7 @@ describe('Task statusMessage after terminal transition', () => { it('should set "payment required" statusMessage for pre-flight 402 path', async () => { const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); + const taskId = await createTaskId(store); // Simulates the pre-flight payment failure path in executeToolAndUpdateTask. // Stored as 'completed' so the SDK's requestStream() delivers the x402 payload @@ -74,7 +74,7 @@ describe('Task statusMessage after terminal transition', () => { it('should set status "cancelled" when task was aborted via signal, not "completed"', async () => { const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); + const taskId = await createTaskId(store); // Simulates the signal-based abort path: executeActorTool returns null, // toolStatus = ABORTED. The fix transitions to 'cancelled' instead of @@ -89,7 +89,7 @@ describe('Task statusMessage after terminal transition', () => { it('should not call updateTaskStatus on cancelled task (402 catch path)', async () => { const store = new InMemoryTaskStore(); - const taskId = await createWorkingTask(store); + const taskId = await createTaskId(store); // Simulate: task is cancelled while actor is running await store.updateTaskStatus(taskId, 'cancelled', 'Cancelled by client'); From f8c119cb2a2591426fafd26d74437980313e6755 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 14:43:39 +0200 Subject: [PATCH 13/14] fix: log non-cancellation storage failures and ensure task result handling continues --- src/mcp/server.ts | 8 +++++++- src/mcp/utils.ts | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b49f7aa0..1220c38b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1173,7 +1173,13 @@ export class ActorsMcpServer { finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); return false; } - throw storeError; + // Non-cancellation storage failure (e.g. broken task store). Log and continue — + // this runs inside a fire-and-forget setImmediate, so throwing would be an + // unhandled rejection. The task is stuck at 'working' and TTL cleanup will remove it. + log.error('[executeToolAndUpdateTask] Failed to store task result', { + taskId, mcpSessionId, error: storeError, + }); + return true; } }; diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 01ad7020..b7c3b0ed 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -61,6 +61,8 @@ export async function isTaskCancelled( export async function storeTaskResultWithMessage( taskStore: TaskStore, taskId: string, + // Always 'completed' — the SDK's requestStream() only delivers results for 'completed' tasks; + // 'failed' tasks yield a generic error and discard the stored result. See res/task_status_workaround.md. status: 'completed', result: Result, statusMessage: string, From 0b8d77ee3e2849df51b24bd576376a577b96ce05 Mon Sep 17 00:00:00 2001 From: Jiri Spilka Date: Thu, 16 Apr 2026 15:04:57 +0200 Subject: [PATCH 14/14] fix: handle task failure when storing results and update task status accordingly --- res/task_status_workaround.md | 2 + src/mcp/server.ts | 18 ++++++-- src/mcp/utils.ts | 6 +-- tests/unit/server.task_execution.test.ts | 58 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/res/task_status_workaround.md b/res/task_status_workaround.md index d51a9c21..14adde28 100644 --- a/res/task_status_workaround.md +++ b/res/task_status_workaround.md @@ -70,6 +70,8 @@ yield { Currently we use `storeTaskResultWithMessage()` which calls `updateTaskStatus('working', message)` then `storeTaskResult(status, result)` — two non-atomic calls. The SDK's `storeTaskResult()` should accept an optional `statusMessage` parameter to make this atomic. +This also leaves a small race with `tasks/cancel`: after the tool has already produced its payload, the task is briefly back in `'working'`, so a concurrent cancel can still win and the computed result is lost. + ### mcpc: `pollTask()` should fetch result for 'completed' tasks **Scope:** `pollTask()` is the detached task polling fallback, used when a task was started via diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1220c38b..1ac6ecb0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1173,13 +1173,23 @@ export class ActorsMcpServer { finishTaskTracking(TOOL_STATUS.ABORTED, callDiagnostics); return false; } - // Non-cancellation storage failure (e.g. broken task store). Log and continue — - // this runs inside a fire-and-forget setImmediate, so throwing would be an - // unhandled rejection. The task is stuck at 'working' and TTL cleanup will remove it. + const failureMessage = errorMsg('failed to store task result'); log.error('[executeToolAndUpdateTask] Failed to store task result', { taskId, mcpSessionId, error: storeError, }); - return true; + try { + await this.taskStore.updateTaskStatus(taskId, 'failed', failureMessage, mcpSessionId); + } catch (statusError) { + log.error('[executeToolAndUpdateTask] Failed to mark task as failed after result storage error', { + taskId, mcpSessionId, error: statusError, + }); + } + finishTaskTracking(TOOL_STATUS.FAILED, { + ...callDiagnostics, + failure_category: FAILURE_CATEGORY.INTERNAL_ERROR, + failure_detail: 'Failed to store task result', + }); + return false; } }; diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index b7c3b0ed..ae35e68c 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -54,9 +54,9 @@ export async function isTaskCancelled( * * WARNING: This is NOT atomic. The SDK's storeTaskResult() does not accept a statusMessage, * so we first call updateTaskStatus('working', message) then storeTaskResult(status, result). - * If the process crashes between the two calls, the task stays 'working' with the final message - * but never transitions to terminal state. This is acceptable because the same crash would leave - * the task stuck regardless — the TTL cleanup will eventually remove it. + * That creates a race: after the tool has already finished, tasks/cancel can still win before + * storeTaskResult() runs, and the computed result is lost. We keep this workaround so tasks/list + * shows the final statusMessage until the SDK supports atomic result + statusMessage storage. */ export async function storeTaskResultWithMessage( taskStore: TaskStore, diff --git a/tests/unit/server.task_execution.test.ts b/tests/unit/server.task_execution.test.ts index 38ab33cc..9b193360 100644 --- a/tests/unit/server.task_execution.test.ts +++ b/tests/unit/server.task_execution.test.ts @@ -72,4 +72,62 @@ describe('ActorsMcpServer task execution', () => { }], }); }); + + it('marks the task as failed when storing the result fails', async () => { + class FailingTaskStore extends InMemoryTaskStore { + override async storeTaskResult(..._args: Parameters) { + throw new Error('task store unavailable'); + } + } + + const taskStore = new FailingTaskStore(); + const server = new ActorsMcpServer({ + taskStore, + telemetry: { enabled: false }, + transportType: 'stdio', + }); + + const task = await taskStore.createTask( + { ttl: 60_000 }, + 'req-1', + { method: 'tools/call', params: { name: 'call-actor' } }, + ); + + const tool = { + type: 'internal', + name: 'call-actor', + description: 'Call Actor', + inputSchema: { type: 'object', properties: {} }, + ajvValidate: vi.fn(), + execution: { + taskSupport: 'optional', + }, + call: vi.fn().mockResolvedValue({ + content: [{ + type: 'text', + text: 'ok', + }], + }), + } as unknown as ToolEntry; + + await (server as unknown as { + executeToolAndUpdateTask: (params: Record) => Promise; + }).executeToolAndUpdateTask({ + taskId: task.taskId, + tool, + toolArgs: {}, + logSafeArgs: {}, + apifyClient: {} as never, + apifyToken: '', + progressToken: undefined, + extra: { + signal: new AbortController().signal, + }, + mcpSessionId: undefined, + }); + + const storedTask = await taskStore.getTask(task.taskId); + expect(storedTask?.status).toBe('failed'); + expect(storedTask?.statusMessage).toBe('[error] call-actor: failed to store task result'); + }); });